Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.65% covered (success)
98.65%
73 / 74
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_Section
98.65% covered (success)
98.65%
73 / 74
88.89% covered (warning)
88.89%
8 / 9
35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 derive_slug
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_available
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 get_default_layout
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_notice
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 get_insertable_widget_types
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 to_array
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 set_props
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
16
 normalize_widget_type_names
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Dashboard Sections API: Dashboard_Section class.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10/**
11 * Represents a dashboard section.
12 *
13 * The server-owned model behind a top-level dashboard tab.
14 */
15final class Dashboard_Section {
16
17    /**
18     * Date-filter surface offering the rolling date-range picker (today, last 7
19     * days, a custom range, …) plus the comparison control. The default.
20     *
21     * @since 0.2.0
22     * @var string
23     */
24    const DATE_FILTER_RANGE = 'range';
25
26    /**
27     * Date-filter surface offering all time plus one entry per calendar year,
28     * for sections whose data is read as whole history rather than as a
29     * rolling window.
30     *
31     * @since 0.2.0
32     * @var string
33     */
34    const DATE_FILTER_YEAR = 'year';
35
36    /**
37     * Date-filter surfaces a section may declare.
38     *
39     * @since 0.2.0
40     * @var string[]
41     */
42    const DATE_FILTERS = array( self::DATE_FILTER_RANGE, self::DATE_FILTER_YEAR );
43
44    /**
45     * Dashboard identifier.
46     *
47     * @var string
48     */
49    public $dashboard_name;
50
51    /**
52     * Section identifier.
53     *
54     * @var string
55     */
56    public $id;
57
58    /**
59     * URL-facing section slug, derived from the identifier.
60     *
61     * @var string
62     */
63    public $slug;
64
65    /**
66     * Display label, naming the section's tab.
67     *
68     * @var string
69     */
70    public $label;
71
72    /**
73     * Section heading, deliberately distinct from the tab label: the tab reads
74     * `Traffic` where the heading reads `Site traffic`. Null falls back to the label.
75     *
76     * @since 0.3.0
77     * @var string|null
78     */
79    public $title = null;
80
81    /**
82     * Sort order.
83     *
84     * @var int
85     */
86    public $order = 10;
87
88    /**
89     * Which shape the section's date filter takes, as one of self::DATE_FILTERS.
90     *
91     * Shape only. Where it renders and what it supports are
92     * self::$date_filter_options.
93     *
94     * @since 0.2.0
95     * @var string
96     */
97    public $date_filter = self::DATE_FILTER_RANGE;
98
99    /**
100     * What the section's date filter supports, and where it renders.
101     *
102     * - `with_date_comparison`: false drops the comparison param from every widget fetch in the
103     *   section, not just the chrome.
104     * - `with_header_date_control`: false hands the control to the section's widgets, which may
105     *   save the range onto the widget instance rather than the URL.
106     * - `with_header_interval_control`: false drops the chart interval control from the header, for
107     *   a section whose charts each save their own.
108     *
109     * @since 0.3.0
110     * @since 0.5.0 Added `with_header_date_control`.
111     * @since 0.12.0 Added `with_header_interval_control`.
112     * @var array
113     */
114    public $date_filter_options = array(
115        'with_date_comparison'         => true,
116        'with_header_date_control'     => true,
117        'with_header_interval_control' => true,
118    );
119
120    /**
121     * Whether the section's data only reaches WordPress.com through the analytics
122     * full sync, so its numbers are incomplete until that sync has finished once.
123     *
124     * @since 0.4.0
125     * @var bool
126     */
127    public $requires_sync = false;
128
129    /**
130     * Whether the plan's Stats date limits apply to the section's window.
131     *
132     * @since $$next-version$$
133     * @var bool
134     */
135    public $follows_plan_date_limits = false;
136
137    /**
138     * Message, or a callback returning one, shown above the section's widgets; null when none.
139     *
140     * @since 0.13.0
141     * @var string|callable|null
142     */
143    private $notice = null;
144
145    /**
146     * Availability flag or callback; null when the registration declared none.
147     *
148     * @var bool|callable|null
149     */
150    private $is_available = null;
151
152    /**
153     * Default layout array or callback.
154     *
155     * @var array|callable
156     */
157    private $default_layout = array();
158
159    /**
160     * Widget type names the inserter offers on top of the default layout's.
161     *
162     * @since 0.13.0
163     * @var string[]
164     */
165    private $insertable_widget_types = array();
166
167    /**
168     * Constructor.
169     *
170     * @param string $dashboard_name Dashboard identifier.
171     * @param string $id             Section identifier.
172     * @param array  $args           Optional. Section arguments.
173     */
174    public function __construct( $dashboard_name, $id, $args = array() ) {
175        $this->dashboard_name = $dashboard_name;
176        $this->id             = $id;
177        $this->slug           = self::derive_slug( $id );
178        $this->label          = $id;
179
180        $this->set_props( $args );
181    }
182
183    /**
184     * Derives the URL-facing slug from a namespaced section identifier.
185     *
186     * @param string $id Section identifier, e.g. `analytics/traffic`.
187     * @return string The segment after the namespace, e.g. `traffic`.
188     */
189    private static function derive_slug( $id ) {
190        $separator = strpos( (string) $id, '/' );
191
192        return false === $separator ? (string) $id : substr( $id, $separator + 1 );
193    }
194
195    /**
196     * Returns whether this section should be exposed.
197     *
198     * @return bool
199     */
200    public function is_available() {
201        // Before sections decided who opens the dashboard, Stats access was the outer gate.
202        if ( null === $this->is_available ) {
203            return Capabilities::current_user_can_view_stats();
204        }
205
206        if ( is_callable( $this->is_available ) ) {
207            return (bool) call_user_func( $this->is_available, $this );
208        }
209
210        return (bool) $this->is_available;
211    }
212
213    /**
214     * Returns the section's default widget layout, run through the default-layout filter.
215     *
216     * @return array Array of widget instances.
217     */
218    public function get_default_layout() {
219        $layout = is_callable( $this->default_layout )
220            ? call_user_func( $this->default_layout, $this )
221            : $this->default_layout;
222        $layout = is_array( $layout ) ? array_values( $layout ) : array();
223
224        /**
225         * Filters a dashboard section's default widget layout.
226         *
227         * Each entry matches the dashboard's widget instance shape: `uuid`, `type`, optional
228         * `attributes`, optional `placement`. Runs for every section, so a callback adding an
229         * instance to one switches on `$section_id`.
230         *
231         * @since 0.8.0 Runs from the section, with its declared layout and its
232         *                         namespaced id; it received an empty array and any alias before.
233         *
234         * @param array             $layout     The section's declared default widget instances.
235         * @param string            $section_id Namespaced section identifier, e.g. `analytics/traffic`.
236         * @param Dashboard_Section $section    The section.
237         */
238        $layout = apply_filters( DASHBOARD_DEFAULT_LAYOUT_FILTER, $layout, $this->id, $this );
239
240        return is_array( $layout ) ? array_values( $layout ) : array();
241    }
242
243    /**
244     * Returns the message to show above the section's widgets, resolved for this request.
245     *
246     * @since 0.13.0
247     *
248     * @return string|null The message, or null when there is none.
249     */
250    public function get_notice() {
251        $notice = is_callable( $this->notice ) ? call_user_func( $this->notice, $this ) : $this->notice;
252
253        return is_string( $notice ) && '' !== $notice ? $notice : null;
254    }
255
256    /**
257     * Returns the widget types the section's inserter offers on top of its default layout, run
258     * through the insertable-types filter.
259     *
260     * @since 0.13.0
261     *
262     * @return string[] Widget type names.
263     */
264    public function get_insertable_widget_types() {
265        /**
266         * Filters the widget types a dashboard section's inserter offers on top of its default layout.
267         *
268         * Runs for every section, so a callback adding a type to one switches on `$section_id`. A
269         * type the widget type registry does not know is never offered.
270         *
271         * @since 0.13.0
272         *
273         * @param string[]          $types      The section's declared widget type names.
274         * @param string            $section_id Namespaced section identifier, e.g. `analytics/traffic`.
275         * @param Dashboard_Section $section    The section.
276         */
277        $types = apply_filters( DASHBOARD_INSERTABLE_WIDGET_TYPES_FILTER, $this->insertable_widget_types, $this->id, $this );
278
279        return self::normalize_widget_type_names( $types );
280    }
281
282    /**
283     * Returns the public REST representation.
284     *
285     * @return array
286     */
287    public function to_array() {
288        return array(
289            'id'                      => $this->id,
290            'slug'                    => $this->slug,
291            'label'                   => $this->label,
292            'title'                   => $this->title,
293            'order'                   => (int) $this->order,
294            'date_filter'             => $this->date_filter,
295            'date_filter_options'     => $this->date_filter_options,
296            'requires_sync'           => $this->requires_sync,
297            'notice'                  => $this->get_notice(),
298            'default_layout'          => $this->get_default_layout(),
299            'insertable_widget_types' => $this->get_insertable_widget_types(),
300        );
301    }
302
303    /**
304     * Hydrates section properties from the args array.
305     *
306     * @param array $args Section arguments.
307     * @return void
308     */
309    private function set_props( $args ) {
310        if ( ! is_array( $args ) ) {
311            return;
312        }
313
314        if ( isset( $args['label'] ) ) {
315            $this->label = (string) $args['label'];
316        }
317
318        // An empty string is a registrant saying "none", not a heading: kept as-is it
319        // would defeat the label fallback and render an `<h2>` with no accessible name.
320        if ( isset( $args['title'] ) ) {
321            $title       = (string) $args['title'];
322            $this->title = '' === $title ? null : $title;
323        }
324
325        if ( isset( $args['order'] ) ) {
326            $this->order = (int) $args['order'];
327        }
328
329        // An unrecognized surface keeps the default rather than reaching the
330        // dashboard, where the frontend has no filter to render for it.
331        if ( isset( $args['date_filter'] ) && in_array( $args['date_filter'], self::DATE_FILTERS, true ) ) {
332            $this->date_filter = (string) $args['date_filter'];
333        }
334
335        // Merged over the defaults so a partial array keeps the rest, and narrowed
336        // to the known options, which is all the dashboard renders.
337        if ( isset( $args['date_filter_options'] ) && is_array( $args['date_filter_options'] ) ) {
338            $options = array_merge( $this->date_filter_options, $args['date_filter_options'] );
339
340            $this->date_filter_options = array(
341                'with_date_comparison'         => (bool) $options['with_date_comparison'],
342                'with_header_date_control'     => (bool) $options['with_header_date_control'],
343                'with_header_interval_control' => (bool) $options['with_header_interval_control'],
344            );
345        }
346
347        if ( isset( $args['requires_sync'] ) ) {
348            $this->requires_sync = (bool) $args['requires_sync'];
349        }
350
351        if ( isset( $args['follows_plan_date_limits'] ) ) {
352            $this->follows_plan_date_limits = (bool) $args['follows_plan_date_limits'];
353        }
354
355        if ( array_key_exists( 'notice', $args ) ) {
356            $this->notice = $args['notice'];
357        }
358
359        if ( array_key_exists( 'is_available', $args ) ) {
360            $this->is_available = $args['is_available'];
361        }
362
363        if ( array_key_exists( 'default_layout', $args ) ) {
364            $this->default_layout = $args['default_layout'];
365        }
366
367        if ( isset( $args['insertable_widget_types'] ) ) {
368            $this->insertable_widget_types = self::normalize_widget_type_names( $args['insertable_widget_types'] );
369        }
370    }
371
372    /**
373     * Narrows a value to its distinct, non-empty widget type names, as a list.
374     *
375     * @param mixed $types Candidate widget type names.
376     * @return string[]
377     */
378    private static function normalize_widget_type_names( $types ) {
379        if ( ! is_array( $types ) ) {
380            return array();
381        }
382
383        $names = array_filter(
384            $types,
385            static function ( $type ) {
386                return is_string( $type ) && '' !== $type;
387            }
388        );
389
390        return array_values( array_unique( $names ) );
391    }
392}