Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.91% covered (success)
96.91%
188 / 194
93.33% covered (success)
93.33%
14 / 15
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\is_woocommerce_dashboard_section_available
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\is_woocommerce_dashboard_section_available_to_current_user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\is_subscribers_dashboard_section_available
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\get_woocommerce_dashboard_section_default_layout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\register_default_dashboard_sections
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\bootstrap_dashboard_sections
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
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%
68 / 68
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: registry bootstrap and REST routes.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10use Automattic\Jetpack\Modules;
11
12require_once __DIR__ . '/dashboard-layout.php';
13require_once __DIR__ . '/dashboard-grammar.php';
14require_once __DIR__ . '/class-dashboard-section.php';
15require_once __DIR__ . '/class-dashboard-section-registry.php';
16
17/**
18 * Filter through which WooCommerce section availability is resolved.
19 */
20const WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER = 'jetpack_premium_analytics_woocommerce_dashboard_section_available';
21
22/**
23 * Filter through which Subscribers section availability is resolved.
24 */
25const SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER = 'jetpack_premium_analytics_subscribers_dashboard_section_available';
26
27/**
28 * Registers a dashboard section.
29 *
30 * @param string $dashboard_name Dashboard identifier.
31 * @param string $id             Section identifier.
32 * @param array  $args           Optional. Section arguments.
33 * @return Dashboard_Section|false The registered section on success, or false on failure.
34 */
35function register_dashboard_section( $dashboard_name, $id, $args = array() ) {
36    return Dashboard_Section_Registry::get_instance()->register( $dashboard_name, $id, $args );
37}
38
39/**
40 * Retrieves a registered dashboard section.
41 *
42 * @param string $dashboard_name Dashboard identifier.
43 * @param string $id             Section identifier.
44 * @return Dashboard_Section|null The registered section, or null when absent.
45 */
46function get_registered_dashboard_section( $dashboard_name, $id ) {
47    return Dashboard_Section_Registry::get_instance()->get_registered( $dashboard_name, $id );
48}
49
50/**
51 * Retrieves available dashboard sections.
52 *
53 * @param string $dashboard_name Dashboard identifier.
54 * @return Dashboard_Section[] Ordered list of available sections.
55 */
56function get_available_dashboard_sections( $dashboard_name ) {
57    return Dashboard_Section_Registry::get_instance()->get_available_sections( $dashboard_name );
58}
59
60/**
61 * Whether the WooCommerce dashboard section should be exposed.
62 *
63 * @return bool True when WooCommerce is active.
64 */
65function is_woocommerce_dashboard_section_available() {
66    $is_available = class_exists( 'WooCommerce' ) || function_exists( 'WC' );
67
68    /**
69     * Filters whether the WooCommerce dashboard section is available.
70     *
71     * @param bool $is_available Whether WooCommerce was detected in the current request.
72     */
73    return (bool) apply_filters( WOOCOMMERCE_DASHBOARD_SECTION_AVAILABLE_FILTER, $is_available );
74}
75
76/**
77 * Whether the current user should be shown the WooCommerce dashboard section.
78 *
79 * The sibling is_woocommerce_dashboard_section_available() answers "is
80 * WooCommerce here"; this adds "and may this reader see store data".
81 *
82 * @since 0.1.0
83 *
84 * @return bool
85 */
86function is_woocommerce_dashboard_section_available_to_current_user() {
87    return is_woocommerce_dashboard_section_available() && Capabilities::current_user_can_view_store_reports();
88}
89
90/**
91 * Whether the Subscribers dashboard section should be exposed.
92 *
93 * Sites without Jetpack have no module state to check, so the section remains
94 * available. Modules::is_active() also returns true on WPCOM Simple.
95 *
96 * @since 0.3.0
97 *
98 * @return bool True when the subscriptions module is active.
99 */
100function is_subscribers_dashboard_section_available() {
101    $is_available = ! class_exists( 'Jetpack' ) || ( new Modules() )->is_active( 'subscriptions' );
102
103    /**
104     * Filters whether the Subscribers dashboard section is available.
105     *
106     * @since 0.3.0
107     *
108     * @param bool $is_available Whether the subscriptions module was detected in the current request.
109     */
110    return (bool) apply_filters( SUBSCRIBERS_DASHBOARD_SECTION_AVAILABLE_FILTER, $is_available );
111}
112
113/**
114 * Returns the default widget layout for the WooCommerce dashboard section.
115 *
116 * @return array Array of widget instances.
117 */
118function get_woocommerce_dashboard_section_default_layout() {
119    return get_dashboard_default_layout_for( 'woocommerce/store' );
120}
121
122/**
123 * Registers the default Premium Analytics dashboard sections.
124 *
125 * @return void
126 */
127function register_default_dashboard_sections() {
128    $registry = Dashboard_Section_Registry::get_instance();
129
130    $sections = array(
131        'analytics/traffic'     => array(
132            'label'          => __( 'Traffic', 'jetpack-premium-analytics-pkg' ),
133            'title'          => __( 'Site traffic', 'jetpack-premium-analytics-pkg' ),
134            'description'    => __( 'Views, visitors, and where they came from.', 'jetpack-premium-analytics-pkg' ),
135            'order'          => 10,
136            'default_layout' => static function () {
137                return get_dashboard_default_layout_for( 'analytics/traffic' );
138            },
139        ),
140        'analytics/insights'    => array(
141            'label'               => __( 'Insights', 'jetpack-premium-analytics-pkg' ),
142            'title'               => __( 'Activity insights', 'jetpack-premium-analytics-pkg' ),
143            'description'         => __( 'Longer-term patterns in your content and audience.', 'jetpack-premium-analytics-pkg' ),
144            'order'               => 20,
145            // Insights reads whole history: all time and single years instead of
146            // the rolling picker, with nothing to compare them against.
147            'date_filter'         => Dashboard_Section::DATE_FILTER_YEAR,
148            'date_filter_options' => array(
149                'with_date_comparison' => false,
150            ),
151            'default_layout'      => static function () {
152                return get_dashboard_default_layout_for( 'analytics/insights' );
153            },
154        ),
155        'analytics/subscribers' => array(
156            'label'          => __( 'Subscribers', 'jetpack-premium-analytics-pkg' ),
157            'title'          => __( 'Subscribers stats', 'jetpack-premium-analytics-pkg' ),
158            'description'    => __( 'How your subscriber list is growing, and how your emails land.', 'jetpack-premium-analytics-pkg' ),
159            'order'          => 30,
160            'is_available'   => __NAMESPACE__ . '\\is_subscribers_dashboard_section_available',
161            'default_layout' => static function () {
162                return get_dashboard_default_layout_for( 'analytics/subscribers' );
163            },
164        ),
165        // Store registers no heading of its own, so it falls back to the label.
166        'woocommerce/store'     => array(
167            'label'          => __( 'Store', 'jetpack-premium-analytics-pkg' ),
168            'description'    => __( 'Sales, orders, and what your customers are buying.', 'jetpack-premium-analytics-pkg' ),
169            'order'          => 40,
170            'is_available'   => __NAMESPACE__ . '\\is_woocommerce_dashboard_section_available_to_current_user',
171            // Nothing backfills historical orders to WordPress.com but the analytics
172            // full sync. The site sections above read data it already holds.
173            'requires_sync'  => true,
174            'default_layout' => __NAMESPACE__ . '\\get_woocommerce_dashboard_section_default_layout',
175        ),
176    );
177
178    foreach ( $sections as $id => $args ) {
179        if ( ! $registry->is_registered( DASHBOARD_NAME, $id ) ) {
180            register_dashboard_section( DASHBOARD_NAME, $id, $args );
181        }
182    }
183}
184
185/**
186 * Hydrates the dashboard section registry.
187 *
188 * @return void
189 */
190function bootstrap_dashboard_sections() {
191    if ( did_action( 'init' ) ) {
192        register_default_dashboard_sections();
193    } else {
194        add_action( 'init', __NAMESPACE__ . '\\register_default_dashboard_sections' );
195    }
196}
197
198/**
199 * Whether the current user can access dashboard section routes.
200 *
201 * @return bool
202 */
203function check_dashboard_sections_permission() {
204    return Capabilities::current_user_can_view_analytics();
205}
206
207/**
208 * Resolves a route section, including availability checks.
209 *
210 * @param string $dashboard_name Dashboard identifier.
211 * @param string $section_id     Section identifier.
212 * @return Dashboard_Section|\WP_Error Registered available section, or error.
213 */
214function get_available_dashboard_section_for_route( $dashboard_name, $section_id ) {
215    $section = get_registered_dashboard_section( $dashboard_name, $section_id );
216
217    if ( ! $section ) {
218        return new \WP_Error(
219            'dashboard_section_not_found',
220            __( 'Dashboard section not found.', 'jetpack-premium-analytics-pkg' ),
221            array( 'status' => 404 )
222        );
223    }
224
225    if ( ! $section->is_available() ) {
226        return new \WP_Error(
227            'dashboard_section_unavailable',
228            __( 'Dashboard section is not available.', 'jetpack-premium-analytics-pkg' ),
229            array( 'status' => 404 )
230        );
231    }
232
233    return $section;
234}
235
236/**
237 * REST schema for one dashboard section, as returned by the sections route.
238 *
239 * The dashboard's frontend mirrors this shape in
240 * `routes/dashboard/config/sections.ts`, and WPCOM serves the same route for
241 * Simple sites (see AGENTS.md), so both are consumers of this contract.
242 *
243 * @since 0.2.0
244 *
245 * @return array The JSON schema for a dashboard section.
246 */
247function get_dashboard_section_schema() {
248    return array(
249        '$schema'    => 'http://json-schema.org/draft-04/schema#',
250        'title'      => 'jetpack-premium-analytics-dashboard-section',
251        'type'       => 'object',
252        'properties' => array(
253            'id'                  => array(
254                'description' => __( 'Namespaced section identifier.', 'jetpack-premium-analytics-pkg' ),
255                'type'        => 'string',
256                'readonly'    => true,
257            ),
258            'slug'                => array(
259                'description' => __( 'URL-facing section slug, derived from the identifier.', 'jetpack-premium-analytics-pkg' ),
260                'type'        => 'string',
261                'readonly'    => true,
262            ),
263            'label'               => array(
264                'description' => __( 'Translated display label, naming the section tab.', 'jetpack-premium-analytics-pkg' ),
265                'type'        => 'string',
266                'readonly'    => true,
267            ),
268            'title'               => array(
269                'description' => __( 'Translated section heading, distinct from the tab label. Null falls back to the label.', 'jetpack-premium-analytics-pkg' ),
270                'type'        => array( 'string', 'null' ),
271                'readonly'    => true,
272            ),
273            'description'         => array(
274                'description' => __( 'Translated section description, shown as the page subtitle while the section is active.', 'jetpack-premium-analytics-pkg' ),
275                'type'        => array( 'string', 'null' ),
276                'readonly'    => true,
277            ),
278            'order'               => array(
279                'description' => __( 'Sort order, ascending.', 'jetpack-premium-analytics-pkg' ),
280                'type'        => 'integer',
281                'readonly'    => true,
282            ),
283            'date_filter'         => array(
284                'description' => __( 'Which date filter the section header offers: the rolling date range, or all time plus single years.', 'jetpack-premium-analytics-pkg' ),
285                'type'        => 'string',
286                'enum'        => Dashboard_Section::DATE_FILTERS,
287                'default'     => Dashboard_Section::DATE_FILTER_RANGE,
288                'readonly'    => true,
289            ),
290            'date_filter_options' => array(
291                'description' => __( 'Which optional controls the section date filter offers.', 'jetpack-premium-analytics-pkg' ),
292                'type'        => 'object',
293                'properties'  => array(
294                    'with_date_comparison' => array(
295                        'description' => __( 'Whether the section header offers the period-over-period comparison control.', 'jetpack-premium-analytics-pkg' ),
296                        'type'        => 'boolean',
297                        'default'     => true,
298                    ),
299                ),
300                'readonly'    => true,
301            ),
302            'requires_sync'       => array(
303                'description' => __( 'Whether the section\'s numbers stay incomplete until the analytics initial full sync has finished.', 'jetpack-premium-analytics-pkg' ),
304                'type'        => 'boolean',
305                'default'     => false,
306                'readonly'    => true,
307            ),
308            'default_layout'      => array(
309                'description' => __( 'Bundled default widget layout.', 'jetpack-premium-analytics-pkg' ),
310                'type'        => 'array',
311                'items'       => array( 'type' => 'object' ),
312                'readonly'    => true,
313            ),
314        ),
315    );
316}
317
318/**
319 * REST callback returning available dashboard sections.
320 *
321 * @param \WP_REST_Request $request REST request carrying the dashboard name.
322 * @return \WP_REST_Response
323 */
324function get_dashboard_sections_response( $request ) {
325    $sections = array_map(
326        static function ( Dashboard_Section $section ) {
327            return $section->to_array();
328        },
329        get_available_dashboard_sections( $request['name'] )
330    );
331
332    return rest_ensure_response( $sections );
333}
334
335/**
336 * REST callback returning a section's default layout.
337 *
338 * @param \WP_REST_Request $request REST request carrying dashboard and section identifiers.
339 * @return \WP_REST_Response|\WP_Error
340 */
341function get_dashboard_section_default_layout_response( $request ) {
342    $section = get_available_dashboard_section_for_route( $request['name'], $request['section'] );
343
344    if ( is_wp_error( $section ) ) {
345        return $section;
346    }
347
348    return rest_ensure_response( $section->get_default_layout() );
349}
350
351/**
352 * Registers dashboard section REST routes.
353 *
354 * @return void
355 */
356function register_dashboard_sections_rest_routes() {
357    register_rest_route(
358        DASHBOARD_REST_NAMESPACE,
359        '/dashboards/(?P<name>' . get_dashboard_name_pattern() . ')/sections',
360        array(
361            array(
362                // A route-level `schema` beside the numerically keyed endpoint list is
363                // register_rest_route()'s own signature, reading to Phan as a mixed array.
364                // @phan-suppress-next-line PhanPluginMixedKeyNoKey
365                'methods'             => \WP_REST_Server::READABLE,
366                'callback'            => __NAMESPACE__ . '\\get_dashboard_sections_response',
367                'permission_callback' => __NAMESPACE__ . '\\check_dashboard_sections_permission',
368                'args'                => array(
369                    'name' => array(
370                        'description' => __( 'Dashboard identifier as produced by the build pipeline.', 'jetpack-premium-analytics-pkg' ),
371                        'type'        => 'string',
372                    ),
373                ),
374            ),
375            'schema' => __NAMESPACE__ . '\\get_dashboard_section_schema',
376        )
377    );
378
379    register_rest_route(
380        DASHBOARD_REST_NAMESPACE,
381        '/dashboards/(?P<name>' . get_dashboard_name_pattern() . ')/sections/(?P<section>' . get_dashboard_section_id_pattern() . ')/default-layout',
382        array(
383            'methods'             => \WP_REST_Server::READABLE,
384            'callback'            => __NAMESPACE__ . '\\get_dashboard_section_default_layout_response',
385            'permission_callback' => __NAMESPACE__ . '\\check_dashboard_sections_permission',
386            'args'                => array(
387                'name'    => array(
388                    'description' => __( 'Dashboard identifier as produced by the build pipeline.', 'jetpack-premium-analytics-pkg' ),
389                    'type'        => 'string',
390                ),
391                'section' => array(
392                    'description' => __( 'Dashboard section identifier.', 'jetpack-premium-analytics-pkg' ),
393                    'type'        => 'string',
394                ),
395            ),
396        )
397    );
398}
399
400bootstrap_dashboard_sections();