Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.59% covered (success)
96.59%
85 / 88
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Single_Report_Page
96.59% covered (success)
96.59%
85 / 88
80.00% covered (warning)
80.00%
8 / 10
52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 derive_path
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_available
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 get_default_layout
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 get_sections
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 to_array
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 set_props
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
14.32
 normalize_sections
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
9
 normalize_controls
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 is_valid_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * A single-report page: the dashboard page about one entity, a post, an author, a video.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10/**
11 * Describes one single-report page registered with the dashboard.
12 *
13 * The record is declarative: the client renders the page from it. What a record cannot
14 * express, the entity's summary and header, lives in a client-side page adapter keyed by `path`.
15 *
16 * @since $$next-version$$
17 */
18class Single_Report_Page {
19
20    /**
21     * Field types a control may declare: the ones the client binds to the page state.
22     *
23     * @var string[]
24     */
25    const CONTROL_FIELD_TYPES = array( 'jpa/report-params' );
26
27    /**
28     * Dashboard the page belongs to.
29     *
30     * @var string
31     */
32    public $dashboard_name;
33
34    /**
35     * Namespaced identifier, e.g. `videopress/video`.
36     *
37     * @var string
38     */
39    public $id;
40
41    /**
42     * URL segment, the public identity of the page: `/report/{path}/{id}`.
43     *
44     * @var string
45     */
46    public $path;
47
48    /**
49     * The entity kind, used as the breadcrumb and as the title fallback.
50     *
51     * @var string
52     */
53    public $label;
54
55    /**
56     * Slug of the dashboard section the page belongs to.
57     *
58     * @var string|null
59     */
60    public $dashboard_section = null;
61
62    /**
63     * Stats feature key a plan may lock; a locked feature sends the reader to the locked reports page.
64     *
65     * @var string|null
66     */
67    public $feature = null;
68
69    /**
70     * Report param that carries the entity id to the widgets.
71     *
72     * @var string
73     */
74    public $scope_param = 'post_id';
75
76    /**
77     * Regex the entity id must match, without delimiters.
78     *
79     * @var string
80     */
81    public $id_pattern = '^[1-9]\d*$';
82
83    /**
84     * Registered icon name (`collection/icon-name`), resolved on the client as widget icons are.
85     *
86     * @var string|null
87     */
88    public $icon = null;
89
90    /**
91     * Header controls: DataViews field declarations, empty for none, null for the default date control.
92     *
93     * @var array[]|null
94     */
95    public $controls = null;
96
97    /**
98     * Reports page of the same entities, for the "Back to …" link of the not-found notice.
99     *
100     * @var string|null
101     */
102    public $reports_page = null;
103
104    /**
105     * Preferences scope the client persists the arrangement under.
106     *
107     * @var string
108     */
109    public $preferences_scope;
110
111    /**
112     * Availability rule: a callable, a bool, or null for the default.
113     *
114     * @var callable|bool|null
115     */
116    private $is_available = null;
117
118    /**
119     * Default layout of a page with one section: widget instances, or a callable returning them.
120     *
121     * @var array|callable
122     */
123    private $default_layout = array();
124
125    /**
126     * Inner sections of a page with inner navigation: `slug`, `label`, `default_layout` and `controls` each.
127     *
128     * @var array[]
129     */
130    private $sections = array();
131
132    /**
133     * Constructor.
134     *
135     * @param string $dashboard_name Dashboard identifier.
136     * @param string $id             Page identifier.
137     * @param array  $args           Page arguments.
138     */
139    public function __construct( $dashboard_name, $id, $args = array() ) {
140        $this->dashboard_name = $dashboard_name;
141        $this->id             = $id;
142        $this->path           = self::derive_path( $id );
143        $this->label          = $id;
144        $this->set_props( $args );
145
146        if ( empty( $this->preferences_scope ) ) {
147            $this->preferences_scope = 'jetpack-premium-analytics/' . $this->path . '-detail';
148        }
149    }
150
151    /**
152     * Default path: the name part of the id.
153     *
154     * @param string $id Page identifier.
155     * @return string
156     */
157    private static function derive_path( $id ) {
158        $separator = strpos( (string) $id, '/' );
159
160        return false === $separator ? (string) $id : substr( $id, $separator + 1 );
161    }
162
163    /**
164     * Whether the page is available to the current reader.
165     *
166     * @return bool
167     */
168    public function is_available() {
169        if ( null === $this->is_available ) {
170            return Capabilities::current_user_can_view_stats();
171        }
172
173        if ( is_callable( $this->is_available ) ) {
174            return (bool) call_user_func( $this->is_available, $this );
175        }
176
177        return (bool) $this->is_available;
178    }
179
180    /**
181     * The page's default layout, or one inner section's, run through the default-layout filter.
182     *
183     * @param string|null $section_slug Inner section, or null for a page with one section.
184     * @return array
185     */
186    public function get_default_layout( $section_slug = null ) {
187        $layout = $this->default_layout;
188
189        if ( null !== $section_slug ) {
190            $layout = array();
191            foreach ( $this->sections as $section ) {
192                if ( $section['slug'] === $section_slug ) {
193                    $layout = $section['default_layout'];
194                    break;
195                }
196            }
197        }
198
199        $layout = is_callable( $layout ) ? call_user_func( $layout, $this, $section_slug ) : $layout;
200        $layout = is_array( $layout ) ? array_values( $layout ) : array();
201
202        /**
203         * Filters the default layout of a single-report page, or of one of its inner sections.
204         *
205         * @since $$next-version$$
206         *
207         * @param array              $layout       Widget instances.
208         * @param string             $page_id      Page identifier.
209         * @param string|null        $section_slug Inner section, or null for a page with one section.
210         * @param Single_Report_Page $page         The page.
211         */
212        $layout = apply_filters( SINGLE_REPORT_PAGE_DEFAULT_LAYOUT_FILTER, $layout, $this->id, $section_slug, $this );
213
214        return is_array( $layout ) ? array_values( $layout ) : array();
215    }
216
217    /**
218     * The inner sections with their filtered layouts, empty for a page with one section.
219     *
220     * @return array[]
221     */
222    public function get_sections() {
223        $sections = array();
224
225        foreach ( $this->sections as $section ) {
226            $sections[] = array(
227                'slug'           => $section['slug'],
228                'label'          => $section['label'],
229                'default_layout' => $this->get_default_layout( $section['slug'] ),
230                'controls'       => $section['controls'],
231            );
232        }
233
234        return $sections;
235    }
236
237    /**
238     * The record the client reads.
239     *
240     * @return array
241     */
242    public function to_array() {
243        return array(
244            'id'                => $this->id,
245            'path'              => $this->path,
246            'label'             => $this->label,
247            'dashboard_section' => $this->dashboard_section,
248            'feature'           => $this->feature,
249            'scope_param'       => $this->scope_param,
250            'id_pattern'        => $this->id_pattern,
251            'icon'              => $this->icon,
252            'controls'          => $this->controls,
253            'reports_page'      => $this->reports_page,
254            'preferences_scope' => $this->preferences_scope,
255            'default_layout'    => empty( $this->sections ) ? $this->get_default_layout() : array(),
256            'sections'          => $this->get_sections(),
257        );
258    }
259
260    /**
261     * Copies the recognized arguments onto the record.
262     *
263     * @param array $args Page arguments.
264     */
265    private function set_props( $args ) {
266        if ( ! is_array( $args ) ) {
267            return;
268        }
269
270        if ( isset( $args['path'] ) && self::is_valid_slug( $args['path'] ) ) {
271            $this->path = $args['path'];
272        }
273
274        foreach ( array( 'label', 'dashboard_section', 'feature', 'scope_param', 'id_pattern', 'reports_page', 'preferences_scope' ) as $key ) {
275            if ( isset( $args[ $key ] ) && is_string( $args[ $key ] ) && '' !== $args[ $key ] ) {
276                $this->$key = $args[ $key ];
277            }
278        }
279
280        if ( isset( $args['icon'] ) ) {
281            $this->icon = sanitize_widget_icon( $args['icon'] );
282        }
283
284        if ( array_key_exists( 'controls', $args ) ) {
285            $this->controls = self::normalize_controls( $args['controls'] );
286        }
287
288        if ( array_key_exists( 'is_available', $args ) ) {
289            $this->is_available = $args['is_available'];
290        }
291
292        if ( array_key_exists( 'default_layout', $args ) ) {
293            $this->default_layout = $args['default_layout'];
294        }
295
296        if ( isset( $args['sections'] ) && is_array( $args['sections'] ) ) {
297            $this->sections = self::normalize_sections( $args['sections'] );
298        }
299    }
300
301    /**
302     * Keeps the inner sections that carry a valid slug, deduplicated by slug.
303     *
304     * @param array $sections Section declarations.
305     * @return array[]
306     */
307    private static function normalize_sections( $sections ) {
308        $normalized = array();
309
310        foreach ( $sections as $section ) {
311            if ( ! is_array( $section ) || ! isset( $section['slug'] ) || ! self::is_valid_slug( $section['slug'] ) || isset( $normalized[ $section['slug'] ] ) ) {
312                continue;
313            }
314
315            $normalized[ $section['slug'] ] = array(
316                'slug'           => $section['slug'],
317                'label'          => isset( $section['label'] ) && is_string( $section['label'] ) && '' !== $section['label'] ? $section['label'] : $section['slug'],
318                'default_layout' => $section['default_layout'] ?? array(),
319                'controls'       => self::normalize_controls( $section['controls'] ?? null ),
320            );
321        }
322
323        return array_values( $normalized );
324    }
325
326    /**
327     * The controls a page or a section declares: the fields of a type the client binds, one per
328     * type; false or an empty list for none; null, the default, for anything else.
329     *
330     * @param mixed $controls Declared controls.
331     * @return array[]|null
332     */
333    private static function normalize_controls( $controls ) {
334        if ( false === $controls || array() === $controls ) {
335            return array();
336        }
337
338        // A control is a DataViews field, as a widget attribute is: the same vocabulary, the same sanitizer.
339        $fields = sanitize_widget_attributes( $controls );
340        if ( null === $fields ) {
341            return null;
342        }
343
344        $by_type = array();
345        foreach ( $fields as $field ) {
346            if ( isset( $field['type'] ) && in_array( $field['type'], self::CONTROL_FIELD_TYPES, true ) && ! isset( $by_type[ $field['type'] ] ) ) {
347                $by_type[ $field['type'] ] = $field;
348            }
349        }
350
351        return $by_type ? array_values( $by_type ) : null;
352    }
353
354    /**
355     * Whether a value is a lowercase slug of letters, numbers and hyphens.
356     *
357     * @param mixed $value Candidate.
358     * @return bool
359     */
360    private static function is_valid_slug( $value ) {
361        return is_string( $value ) && 1 === preg_match( '/^[a-z0-9-]+$/', $value );
362    }
363}