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