Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.27% covered (success)
97.27%
178 / 183
100.00% covered (success)
100.00%
14 / 14
CRAP
n/a
0 / 0
Automattic\Jetpack\PremiumAnalytics\register_dashboard_section
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_registered_dashboard_section
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_available_dashboard_sections
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_available_dashboard_section_slugs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\get_plan_date_limited_section_slugs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\configure_dashboard_sections_script_data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\inject_dashboard_sections_script_data
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\PremiumAnalytics\get_classic_stats_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\check_dashboard_sections_permission
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_available_dashboard_section_for_route
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\get_dashboard_section_schema
100.00% covered (success)
100.00%
86 / 86
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_dashboard_sections_response
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_dashboard_section_default_layout_response
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\register_dashboard_sections_rest_routes
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Dashboard Sections API: the registry helpers, the section script data, and the REST routes.
4 *
5 * The package's own sections register through this API from default-dashboard-sections.php,
6 * the same way a plugin extending the dashboard does.
7 *
8 * @package automattic/jetpack-premium-analytics
9 */
10
11namespace Automattic\Jetpack\PremiumAnalytics;
12
13require_once __DIR__ . '/dashboard-layout.php';
14require_once __DIR__ . '/dashboard-grammar.php';
15require_once __DIR__ . '/rest-namespace.php';
16require_once __DIR__ . '/class-dashboard-section.php';
17require_once __DIR__ . '/class-dashboard-section-registry.php';
18
19/**
20 * Registers a dashboard section.
21 *
22 * @param string $dashboard_name Dashboard identifier.
23 * @param string $id             Section identifier.
24 * @param array  $args           Optional. Section arguments.
25 * @return Dashboard_Section|false The registered section on success, or false on failure.
26 */
27function register_dashboard_section( $dashboard_name, $id, $args = array() ) {
28    return Dashboard_Section_Registry::get_instance()->register( $dashboard_name, $id, $args );
29}
30
31/**
32 * Retrieves a registered dashboard section.
33 *
34 * @param string $dashboard_name Dashboard identifier.
35 * @param string $id             Section identifier.
36 * @return Dashboard_Section|null The registered section, or null when absent.
37 */
38function get_registered_dashboard_section( $dashboard_name, $id ) {
39    return Dashboard_Section_Registry::get_instance()->get_registered( $dashboard_name, $id );
40}
41
42/**
43 * Retrieves available dashboard sections.
44 *
45 * @param string $dashboard_name Dashboard identifier.
46 * @return Dashboard_Section[] Ordered list of available sections.
47 */
48function get_available_dashboard_sections( $dashboard_name ) {
49    return Dashboard_Section_Registry::get_instance()->get_available_sections( $dashboard_name );
50}
51
52/**
53 * Slugs of the tabs the dashboard exposes, for the client's report routes.
54 *
55 * Reads the same sections the tab list does, so a report cannot outlive the tab it sits
56 * behind. Null, never `array()`, while nothing is registered: an empty array means no
57 * tab is available.
58 *
59 * @since 0.6.0
60 *
61 * @return string[]|null
62 */
63function get_available_dashboard_section_slugs() {
64    $registry = Dashboard_Section_Registry::get_instance();
65
66    if ( empty( $registry->get_all_registered( DASHBOARD_NAME ) ) ) {
67        return null;
68    }
69
70    return array_map(
71        static function ( Dashboard_Section $section ) {
72            return $section->slug;
73        },
74        $registry->get_available_sections( DASHBOARD_NAME )
75    );
76}
77
78/**
79 * Slugs of the available tabs whose window follows the plan's Stats date limits.
80 *
81 * @since 0.14.0
82 *
83 * @return string[]
84 */
85function get_plan_date_limited_section_slugs() {
86    $slugs = array();
87
88    foreach ( Dashboard_Section_Registry::get_instance()->get_available_sections( DASHBOARD_NAME ) as $section ) {
89        if ( $section->follows_plan_date_limits ) {
90            $slugs[] = $section->slug;
91        }
92    }
93
94    return $slugs;
95}
96
97/**
98 * Configures the section script data.
99 *
100 * @since 0.6.0
101 *
102 * @return void
103 */
104function configure_dashboard_sections_script_data() {
105    add_filter( 'jetpack_admin_js_script_data', __NAMESPACE__ . '\\inject_dashboard_sections_script_data', 20 );
106}
107
108/**
109 * Injects the available section slugs, those the plan's date limits apply to, and whether the reader may see Stats, into JetpackScriptData.
110 *
111 * The same list travels over REST for the tab bar, but a report route reads no REST before
112 * choosing its redirect, so it reads the slugs from boot data instead.
113 *
114 * @since 0.6.0
115 *
116 * @param array $data The script data passed by the assets package.
117 * @return array
118 */
119function inject_dashboard_sections_script_data( array $data ): array {
120    if ( ! isset( $data['premium_analytics'] ) || ! is_array( $data['premium_analytics'] ) ) {
121        $data['premium_analytics'] = array();
122    }
123
124    // Surfaces outside any section, such as feedback, post to Stats endpoints.
125    $data['premium_analytics']['can_view_stats'] = Capabilities::current_user_can_view_stats();
126
127    // Switching the dashboard off sends the reader to classic Stats, so the client is told where that is.
128    $data['premium_analytics']['classic_stats_url'] = get_classic_stats_url();
129
130    $sections = get_available_dashboard_section_slugs();
131
132    if ( null !== $sections ) {
133        $data['premium_analytics']['sections']                   = $sections;
134        $data['premium_analytics']['plan_date_limited_sections'] = get_plan_date_limited_section_slugs();
135    }
136
137    return $data;
138}
139
140/**
141 * The URL of the classic Stats page, or null while no plugin registers a `stats` menu page.
142 *
143 * Read from the menu rather than `$admin_page_hooks`, which keys top-level pages only: with
144 * Odyssey Stats off, the Jetpack plugin registers the page under its own menu.
145 *
146 * @since 0.14.0
147 *
148 * @return string|null
149 */
150function get_classic_stats_url(): ?string {
151    $url = function_exists( 'menu_page_url' ) ? menu_page_url( 'stats', false ) : '';
152
153    return '' === $url ? null : $url;
154}
155
156/**
157 * Whether the current user can access dashboard section routes.
158 *
159 * @return bool
160 */
161function check_dashboard_sections_permission() {
162    return Capabilities::current_user_can_view_analytics();
163}
164
165/**
166 * Resolves a route section, including availability checks.
167 *
168 * @param string $dashboard_name Dashboard identifier.
169 * @param string $section_id     Section identifier.
170 * @return Dashboard_Section|\WP_Error Registered available section, or error.
171 */
172function get_available_dashboard_section_for_route( $dashboard_name, $section_id ) {
173    $section = get_registered_dashboard_section( $dashboard_name, $section_id );
174
175    if ( ! $section ) {
176        return new \WP_Error(
177            'dashboard_section_not_found',
178            __( 'Dashboard section not found.', 'jetpack-premium-analytics-pkg' ),
179            array( 'status' => 404 )
180        );
181    }
182
183    if ( ! $section->is_available() ) {
184        return new \WP_Error(
185            'dashboard_section_unavailable',
186            __( 'Dashboard section is not available.', 'jetpack-premium-analytics-pkg' ),
187            array( 'status' => 404 )
188        );
189    }
190
191    return $section;
192}
193
194/**
195 * REST schema for one dashboard section, as returned by the sections route.
196 *
197 * Mirrored by the frontend's `sections.ts` and reused by WPCOM for Simple sites (see AGENTS.md).
198 *
199 * @since 0.2.0
200 *
201 * @return array The JSON schema for a dashboard section.
202 */
203function get_dashboard_section_schema() {
204    return array(
205        '$schema'    => 'http://json-schema.org/draft-04/schema#',
206        'title'      => 'jetpack-premium-analytics-dashboard-section',
207        'type'       => 'object',
208        'properties' => array(
209            'id'                      => array(
210                'description' => __( 'Namespaced section identifier.', 'jetpack-premium-analytics-pkg' ),
211                'type'        => 'string',
212                'readonly'    => true,
213            ),
214            'slug'                    => array(
215                'description' => __( 'URL-facing section slug, derived from the identifier.', 'jetpack-premium-analytics-pkg' ),
216                'type'        => 'string',
217                'readonly'    => true,
218            ),
219            'label'                   => array(
220                'description' => __( 'Translated display label, naming the section tab.', 'jetpack-premium-analytics-pkg' ),
221                'type'        => 'string',
222                'readonly'    => true,
223            ),
224            'title'                   => array(
225                'description' => __( 'Translated section heading, distinct from the tab label. Null falls back to the label.', 'jetpack-premium-analytics-pkg' ),
226                'type'        => array( 'string', 'null' ),
227                'readonly'    => true,
228            ),
229            'order'                   => array(
230                'description' => __( 'Sort order, ascending.', 'jetpack-premium-analytics-pkg' ),
231                'type'        => 'integer',
232                'readonly'    => true,
233            ),
234            'date_filter'             => array(
235                'description' => __( 'Which shape the section date filter takes: the rolling date range, or all time plus single years.', 'jetpack-premium-analytics-pkg' ),
236                'type'        => 'string',
237                'enum'        => Dashboard_Section::DATE_FILTERS,
238                'default'     => Dashboard_Section::DATE_FILTER_RANGE,
239                'readonly'    => true,
240            ),
241            'date_filter_options'     => array(
242                'description' => __( 'What the section date filter supports, and where it renders.', 'jetpack-premium-analytics-pkg' ),
243                'type'        => 'object',
244                'properties'  => array(
245                    'with_date_comparison'         => array(
246                        'description' => __( 'Whether the section supports period-over-period comparison at all. When false, no widget in the section receives comparison parameters.', 'jetpack-premium-analytics-pkg' ),
247                        'type'        => 'boolean',
248                        'default'     => true,
249                    ),
250                    'with_header_date_control'     => array(
251                        'description' => __( 'Whether the section header renders the date control. When false, the section widgets host their own.', 'jetpack-premium-analytics-pkg' ),
252                        'type'        => 'boolean',
253                        'default'     => true,
254                    ),
255                    'with_header_interval_control' => array(
256                        'description' => __( 'Whether the section header renders the chart interval control. When false, the section charts host their own.', 'jetpack-premium-analytics-pkg' ),
257                        'type'        => 'boolean',
258                        'default'     => true,
259                    ),
260                ),
261                'readonly'    => true,
262            ),
263            'requires_sync'           => array(
264                'description' => __( 'Whether the section\'s numbers stay incomplete until the analytics initial full sync has finished.', 'jetpack-premium-analytics-pkg' ),
265                'type'        => 'boolean',
266                'default'     => false,
267                'readonly'    => true,
268            ),
269            'notice'                  => array(
270                'description' => __( 'Message shown above the section widgets, or null when there is none.', 'jetpack-premium-analytics-pkg' ),
271                'type'        => array( 'string', 'null' ),
272                'default'     => null,
273                'readonly'    => true,
274            ),
275            'default_layout'          => array(
276                'description' => __( 'Bundled default widget layout.', 'jetpack-premium-analytics-pkg' ),
277                'type'        => 'array',
278                'items'       => array( 'type' => 'object' ),
279                'readonly'    => true,
280            ),
281            'insertable_widget_types' => array(
282                'description' => __( 'Widget types the section inserter offers on top of its default layout.', 'jetpack-premium-analytics-pkg' ),
283                'type'        => 'array',
284                'items'       => array( 'type' => 'string' ),
285                'default'     => array(),
286                'readonly'    => true,
287            ),
288        ),
289    );
290}
291
292/**
293 * REST callback returning available dashboard sections.
294 *
295 * @param \WP_REST_Request $request REST request carrying the dashboard name.
296 * @return \WP_REST_Response
297 */
298function get_dashboard_sections_response( $request ) {
299    $sections = array_map(
300        static function ( Dashboard_Section $section ) {
301            return $section->to_array();
302        },
303        get_available_dashboard_sections( $request['name'] )
304    );
305
306    return rest_ensure_response( $sections );
307}
308
309/**
310 * REST callback returning a section's default layout.
311 *
312 * @param \WP_REST_Request $request REST request carrying dashboard and section identifiers.
313 * @return \WP_REST_Response|\WP_Error
314 */
315function get_dashboard_section_default_layout_response( $request ) {
316    $section = get_available_dashboard_section_for_route( $request['name'], $request['section'] );
317
318    if ( is_wp_error( $section ) ) {
319        return $section;
320    }
321
322    return rest_ensure_response( $section->get_default_layout() );
323}
324
325/**
326 * Registers dashboard section REST routes.
327 *
328 * @return void
329 */
330function register_dashboard_sections_rest_routes() {
331    register_rest_route(
332        DASHBOARD_REST_NAMESPACE,
333        '/dashboards/(?P<name>' . get_dashboard_name_pattern() . ')/sections',
334        array(
335            array(
336                // @phan-suppress-next-line PhanPluginMixedKeyNoKey -- register_rest_route()'s own signature mixes a numerically keyed endpoint list with a route-level `schema` key.
337                'methods'             => \WP_REST_Server::READABLE,
338                'callback'            => __NAMESPACE__ . '\\get_dashboard_sections_response',
339                'permission_callback' => __NAMESPACE__ . '\\check_dashboard_sections_permission',
340                'args'                => array(
341                    'name' => array(
342                        'description' => __( 'Dashboard identifier as produced by the build pipeline.', 'jetpack-premium-analytics-pkg' ),
343                        'type'        => 'string',
344                    ),
345                ),
346            ),
347            'schema' => __NAMESPACE__ . '\\get_dashboard_section_schema',
348        )
349    );
350
351    register_rest_route(
352        DASHBOARD_REST_NAMESPACE,
353        '/dashboards/(?P<name>' . get_dashboard_name_pattern() . ')/sections/(?P<section>' . get_dashboard_section_id_pattern() . ')/default-layout',
354        array(
355            'methods'             => \WP_REST_Server::READABLE,
356            'callback'            => __NAMESPACE__ . '\\get_dashboard_section_default_layout_response',
357            'permission_callback' => __NAMESPACE__ . '\\check_dashboard_sections_permission',
358            'args'                => array(
359                'name'    => array(
360                    'description' => __( 'Dashboard identifier as produced by the build pipeline.', 'jetpack-premium-analytics-pkg' ),
361                    'type'        => 'string',
362                ),
363                'section' => array(
364                    'description' => __( 'Dashboard section identifier.', 'jetpack-premium-analytics-pkg' ),
365                    'type'        => 'string',
366                ),
367            ),
368        )
369    );
370}