Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.61% covered (success)
98.61%
71 / 72
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_Section
98.61% covered (success)
98.61%
71 / 72
88.89% covered (warning)
88.89%
8 / 9
34
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.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
15
 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     * Message, or a callback returning one, shown above the section's widgets; null when none.
131     *
132     * @since $$next-version$$
133     * @var string|callable|null
134     */
135    private $notice = null;
136
137    /**
138     * Availability flag or callback; null when the registration declared none.
139     *
140     * @var bool|callable|null
141     */
142    private $is_available = null;
143
144    /**
145     * Default layout array or callback.
146     *
147     * @var array|callable
148     */
149    private $default_layout = array();
150
151    /**
152     * Widget type names the inserter offers on top of the default layout's.
153     *
154     * @since $$next-version$$
155     * @var string[]
156     */
157    private $insertable_widget_types = array();
158
159    /**
160     * Constructor.
161     *
162     * @param string $dashboard_name Dashboard identifier.
163     * @param string $id             Section identifier.
164     * @param array  $args           Optional. Section arguments.
165     */
166    public function __construct( $dashboard_name, $id, $args = array() ) {
167        $this->dashboard_name = $dashboard_name;
168        $this->id             = $id;
169        $this->slug           = self::derive_slug( $id );
170        $this->label          = $id;
171
172        $this->set_props( $args );
173    }
174
175    /**
176     * Derives the URL-facing slug from a namespaced section identifier.
177     *
178     * @param string $id Section identifier, e.g. `analytics/traffic`.
179     * @return string The segment after the namespace, e.g. `traffic`.
180     */
181    private static function derive_slug( $id ) {
182        $separator = strpos( (string) $id, '/' );
183
184        return false === $separator ? (string) $id : substr( $id, $separator + 1 );
185    }
186
187    /**
188     * Returns whether this section should be exposed.
189     *
190     * @return bool
191     */
192    public function is_available() {
193        // Before sections decided who opens the dashboard, Stats access was the outer gate.
194        if ( null === $this->is_available ) {
195            return Capabilities::current_user_can_view_stats();
196        }
197
198        if ( is_callable( $this->is_available ) ) {
199            return (bool) call_user_func( $this->is_available, $this );
200        }
201
202        return (bool) $this->is_available;
203    }
204
205    /**
206     * Returns the section's default widget layout, run through the default-layout filter.
207     *
208     * @return array Array of widget instances.
209     */
210    public function get_default_layout() {
211        $layout = is_callable( $this->default_layout )
212            ? call_user_func( $this->default_layout, $this )
213            : $this->default_layout;
214        $layout = is_array( $layout ) ? array_values( $layout ) : array();
215
216        /**
217         * Filters a dashboard section's default widget layout.
218         *
219         * Each entry matches the dashboard's widget instance shape: `uuid`, `type`, optional
220         * `attributes`, optional `placement`. Runs for every section, so a callback adding an
221         * instance to one switches on `$section_id`.
222         *
223         * @since 0.8.0 Runs from the section, with its declared layout and its
224         *                         namespaced id; it received an empty array and any alias before.
225         *
226         * @param array             $layout     The section's declared default widget instances.
227         * @param string            $section_id Namespaced section identifier, e.g. `analytics/traffic`.
228         * @param Dashboard_Section $section    The section.
229         */
230        $layout = apply_filters( DASHBOARD_DEFAULT_LAYOUT_FILTER, $layout, $this->id, $this );
231
232        return is_array( $layout ) ? array_values( $layout ) : array();
233    }
234
235    /**
236     * Returns the message to show above the section's widgets, resolved for this request.
237     *
238     * @since $$next-version$$
239     *
240     * @return string|null The message, or null when there is none.
241     */
242    public function get_notice() {
243        $notice = is_callable( $this->notice ) ? call_user_func( $this->notice, $this ) : $this->notice;
244
245        return is_string( $notice ) && '' !== $notice ? $notice : null;
246    }
247
248    /**
249     * Returns the widget types the section's inserter offers on top of its default layout, run
250     * through the insertable-types filter.
251     *
252     * @since $$next-version$$
253     *
254     * @return string[] Widget type names.
255     */
256    public function get_insertable_widget_types() {
257        /**
258         * Filters the widget types a dashboard section's inserter offers on top of its default layout.
259         *
260         * Runs for every section, so a callback adding a type to one switches on `$section_id`. A
261         * type the widget type registry does not know is never offered.
262         *
263         * @since $$next-version$$
264         *
265         * @param string[]          $types      The section's declared widget type names.
266         * @param string            $section_id Namespaced section identifier, e.g. `analytics/traffic`.
267         * @param Dashboard_Section $section    The section.
268         */
269        $types = apply_filters( DASHBOARD_INSERTABLE_WIDGET_TYPES_FILTER, $this->insertable_widget_types, $this->id, $this );
270
271        return self::normalize_widget_type_names( $types );
272    }
273
274    /**
275     * Returns the public REST representation.
276     *
277     * @return array
278     */
279    public function to_array() {
280        return array(
281            'id'                      => $this->id,
282            'slug'                    => $this->slug,
283            'label'                   => $this->label,
284            'title'                   => $this->title,
285            'order'                   => (int) $this->order,
286            'date_filter'             => $this->date_filter,
287            'date_filter_options'     => $this->date_filter_options,
288            'requires_sync'           => $this->requires_sync,
289            'notice'                  => $this->get_notice(),
290            'default_layout'          => $this->get_default_layout(),
291            'insertable_widget_types' => $this->get_insertable_widget_types(),
292        );
293    }
294
295    /**
296     * Hydrates section properties from the args array.
297     *
298     * @param array $args Section arguments.
299     * @return void
300     */
301    private function set_props( $args ) {
302        if ( ! is_array( $args ) ) {
303            return;
304        }
305
306        if ( isset( $args['label'] ) ) {
307            $this->label = (string) $args['label'];
308        }
309
310        // An empty string is a registrant saying "none", not a heading: kept as-is it
311        // would defeat the label fallback and render an `<h2>` with no accessible name.
312        if ( isset( $args['title'] ) ) {
313            $title       = (string) $args['title'];
314            $this->title = '' === $title ? null : $title;
315        }
316
317        if ( isset( $args['order'] ) ) {
318            $this->order = (int) $args['order'];
319        }
320
321        // An unrecognized surface keeps the default rather than reaching the
322        // dashboard, where the frontend has no filter to render for it.
323        if ( isset( $args['date_filter'] ) && in_array( $args['date_filter'], self::DATE_FILTERS, true ) ) {
324            $this->date_filter = (string) $args['date_filter'];
325        }
326
327        // Merged over the defaults so a partial array keeps the rest, and narrowed
328        // to the known options, which is all the dashboard renders.
329        if ( isset( $args['date_filter_options'] ) && is_array( $args['date_filter_options'] ) ) {
330            $options = array_merge( $this->date_filter_options, $args['date_filter_options'] );
331
332            $this->date_filter_options = array(
333                'with_date_comparison'         => (bool) $options['with_date_comparison'],
334                'with_header_date_control'     => (bool) $options['with_header_date_control'],
335                'with_header_interval_control' => (bool) $options['with_header_interval_control'],
336            );
337        }
338
339        if ( isset( $args['requires_sync'] ) ) {
340            $this->requires_sync = (bool) $args['requires_sync'];
341        }
342
343        if ( array_key_exists( 'notice', $args ) ) {
344            $this->notice = $args['notice'];
345        }
346
347        if ( array_key_exists( 'is_available', $args ) ) {
348            $this->is_available = $args['is_available'];
349        }
350
351        if ( array_key_exists( 'default_layout', $args ) ) {
352            $this->default_layout = $args['default_layout'];
353        }
354
355        if ( isset( $args['insertable_widget_types'] ) ) {
356            $this->insertable_widget_types = self::normalize_widget_type_names( $args['insertable_widget_types'] );
357        }
358    }
359
360    /**
361     * Narrows a value to its distinct, non-empty widget type names, as a list.
362     *
363     * @param mixed $types Candidate widget type names.
364     * @return string[]
365     */
366    private static function normalize_widget_type_names( $types ) {
367        if ( ! is_array( $types ) ) {
368            return array();
369        }
370
371        $names = array_filter(
372            $types,
373            static function ( $type ) {
374                return is_string( $type ) && '' !== $type;
375            }
376        );
377
378        return array_values( array_unique( $names ) );
379    }
380}