Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.22% covered (success)
93.22%
344 / 369
62.50% covered (warning)
62.50%
5 / 8
CRAP
n/a
0 / 0
Automattic\Jetpack\PremiumAnalytics\inject_dashboard_default_layout
90.32% covered (success)
90.32%
28 / 31
0.00% covered (danger)
0.00%
0 / 1
12.13
Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_layout_for
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_layout_response
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Automattic\Jetpack\PremiumAnalytics\register_dashboard_default_layout_route
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_widget_instance
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_section_layouts
100.00% covered (success)
100.00%
280 / 280
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_section_id_for
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\seed_default_dashboard_layout
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Dashboard Layout: Premium Analytics server-side defaults.
4 *
5 * Premium Analytics owns its dashboard, so it ships its own default layout
6 * rather than relying on the core dashboard endpoint (which is Gutenberg-only
7 * and returns the core dashboard's widgets). Mirrors the two mechanisms the
8 * core experiment uses:
9 *   1. a `get_user_metadata` injection that surfaces the default through the
10 *      `@wordpress/preferences` store on first load, and
11 *   2. a REST route the client's "reset to default" action reads.
12 *
13 * The scope, key, dashboard name, and REST namespace are constants so they can
14 * be renamed in one place — e.g. to fully isolate the stored preference from
15 * the core dashboard's. These must match `routes/dashboard/hooks/constants.ts`.
16 *
17 * @package automattic/jetpack-premium-analytics
18 */
19
20namespace Automattic\Jetpack\PremiumAnalytics;
21
22require_once __DIR__ . '/dashboard-grammar.php';
23
24/**
25 * Preferences scope under which the dashboard layout is stored. Mirrors the
26 * scope read by the dashboard's JS hooks.
27 */
28const DASHBOARD_LAYOUT_SCOPE = 'jetpack-premium-analytics/dashboard';
29
30/**
31 * Preferences key under DASHBOARD_LAYOUT_SCOPE that holds the layout array.
32 */
33const DASHBOARD_LAYOUT_KEY = 'dashboardLayout';
34
35/**
36 * Preferences key under DASHBOARD_LAYOUT_SCOPE that holds per-section layouts.
37 */
38const DASHBOARD_SECTION_LAYOUTS_KEY = 'dashboardSectionLayouts';
39
40/**
41 * Identifier of the Premium Analytics dashboard, formatted as `<plugin>_<page>`
42 * to match the underscore form produced by the wp-build pipeline. Used as the
43 * `{name}` segment of the REST route and as the seed filter's target.
44 */
45const DASHBOARD_NAME = 'jetpack-premium-analytics_dashboard';
46
47/**
48 * REST namespace that exposes the dashboard's default layout.
49 */
50const DASHBOARD_REST_NAMESPACE = 'jetpack/v4';
51
52/**
53 * Filter through which the default layout for a dashboard is resolved.
54 */
55const DASHBOARD_DEFAULT_LAYOUT_FILTER = 'jetpack_premium_analytics_dashboard_default_layout';
56
57/**
58 * Preference keys used by the dashboard route's tabbed sections.
59 */
60const DASHBOARD_TRAFFIC_SECTION_ID     = 'traffic';
61const DASHBOARD_INSIGHTS_SECTION_ID    = 'insights';
62const DASHBOARD_SUBSCRIBERS_SECTION_ID = 'subscribers';
63const DASHBOARD_STORE_SECTION_ID       = 'store';
64
65/**
66 * Injects the registered default dashboard layout into the user's
67 * `persisted_preferences` read when the stored layout is empty.
68 *
69 * Hooks into `get_user_metadata` so the default propagates through the same
70 * persistence layer the dashboard's JS layer reads from. The JS side stays
71 * oblivious: a default and a user-saved layout look identical at the
72 * preferences-store boundary.
73 *
74 * @global \wpdb $wpdb WordPress database abstraction object.
75 *
76 * @param mixed  $value    The pre-fetched value, or null to let the meta API
77 *                         resolve normally.
78 * @param int    $user_id  User ID.
79 * @param string $meta_key Meta key being read.
80 * @return mixed The original value, or a single-element array containing the
81 *               extended persisted preferences.
82 */
83function inject_dashboard_default_layout( $value, $user_id, $meta_key ) {
84    global $wpdb;
85
86    $expected_key = $wpdb->get_blog_prefix() . 'persisted_preferences';
87    if ( $meta_key !== $expected_key ) {
88        return $value;
89    }
90
91    // Avoid recursion when reading the user meta.
92    remove_filter( 'get_user_metadata', __FUNCTION__, 99 );
93    $base = get_user_meta( $user_id, $meta_key, true );
94    add_filter( 'get_user_metadata', __FUNCTION__, 99, 3 );
95
96    if ( ! is_array( $base ) ) {
97        $base = array();
98    }
99
100    $committed = $base[ DASHBOARD_LAYOUT_SCOPE ][ DASHBOARD_LAYOUT_KEY ] ?? array();
101    $updated   = false;
102
103    if ( ! isset( $base[ DASHBOARD_LAYOUT_SCOPE ] ) || ! is_array( $base[ DASHBOARD_LAYOUT_SCOPE ] ) ) {
104        $base[ DASHBOARD_LAYOUT_SCOPE ] = array();
105    }
106
107    if ( empty( $committed ) ) {
108        $default = get_dashboard_default_layout_for( DASHBOARD_NAME );
109
110        if ( ! empty( $default ) ) {
111            $base[ DASHBOARD_LAYOUT_SCOPE ][ DASHBOARD_LAYOUT_KEY ] = $default;
112            $updated = true;
113        }
114    }
115
116    $section_layouts = $base[ DASHBOARD_LAYOUT_SCOPE ][ DASHBOARD_SECTION_LAYOUTS_KEY ] ?? array();
117
118    if ( ! is_array( $section_layouts ) ) {
119        $section_layouts = array();
120    }
121
122    foreach ( array_keys( get_dashboard_default_section_layouts() ) as $section_id ) {
123        if ( array_key_exists( $section_id, $section_layouts ) ) {
124            continue;
125        }
126
127        $section_default = get_dashboard_default_layout_for( $section_id );
128
129        if ( ! empty( $section_default ) ) {
130            $section_layouts[ $section_id ] = $section_default;
131            $updated                        = true;
132        }
133    }
134
135    if ( $updated ) {
136        $base[ DASHBOARD_LAYOUT_SCOPE ][ DASHBOARD_SECTION_LAYOUTS_KEY ] = $section_layouts;
137
138        return array( $base );
139    }
140
141    return $value;
142}
143add_filter( 'get_user_metadata', __NAMESPACE__ . '\\inject_dashboard_default_layout', 99, 3 );
144
145/**
146 * Resolves the default layout registered for a dashboard.
147 *
148 * Returns a fresh evaluation of the filter chain each call, so callers always
149 * see the current code default rather than a hydrated copy.
150 *
151 * @param string $dashboard_name Identifier of the dashboard or dashboard section.
152 * @return array Array of widget instances (possibly empty).
153 */
154function get_dashboard_default_layout_for( $dashboard_name ) {
155    /**
156     * Filters the default dashboard layout served to users who have not
157     * customized theirs.
158     *
159     * Each entry should match the dashboard's widget instance shape: `uuid`,
160     * `type`, optional `attributes`, optional `placement`.
161     *
162     * @param array  $default_layout Default array of widget instances.
163     * @param string $dashboard_name Identifier of the dashboard or dashboard
164     *                               section receiving the default. Callbacks
165     *                               targeting a specific default should switch
166     *                               on this value.
167     */
168    $default = apply_filters( DASHBOARD_DEFAULT_LAYOUT_FILTER, array(), $dashboard_name );
169
170    return is_array( $default ) ? array_values( $default ) : array();
171}
172
173/**
174 * REST callback returning the default layout for the requested dashboard.
175 *
176 * @param \WP_REST_Request $request REST request carrying the dashboard name.
177 * @return \WP_REST_Response Response wrapping the default layout array.
178 */
179function get_dashboard_default_layout_response( $request ) {
180    return rest_ensure_response( get_dashboard_default_layout_for( $request['name'] ) );
181}
182
183/**
184 * Registers the REST route that exposes per-dashboard default layouts.
185 *
186 * @return void
187 */
188function register_dashboard_default_layout_route() {
189    register_rest_route(
190        DASHBOARD_REST_NAMESPACE,
191        '/dashboards/(?P<name>' . get_dashboard_name_pattern() . ')/default-layout',
192        array(
193            'methods'             => \WP_REST_Server::READABLE,
194            'callback'            => __NAMESPACE__ . '\\get_dashboard_default_layout_response',
195            'permission_callback' => static function () {
196                return current_user_can( 'manage_options' );
197            },
198            'args'                => array(
199                'name' => array(
200                    'description' => __( 'Dashboard identifier as produced by the build pipeline.', 'jetpack-premium-analytics' ),
201                    'type'        => 'string',
202                ),
203            ),
204        )
205    );
206}
207add_action( 'rest_api_init', __NAMESPACE__ . '\\register_dashboard_default_layout_route' );
208
209/**
210 * Builds a widget instance for bundled dashboard defaults.
211 *
212 * @param string $uuid       Widget instance UUID.
213 * @param string $type       Widget type.
214 * @param int    $order      Widget placement order.
215 * @param int    $width      Widget placement width.
216 * @param int    $height     Widget placement height.
217 * @param array  $attributes Optional widget attributes.
218 * @return array Widget instance.
219 */
220function get_dashboard_default_widget_instance(
221    $uuid,
222    $type,
223    $order,
224    $width = 1,
225    $height = 1,
226    $attributes = array()
227) {
228    $widget = array(
229        'uuid' => $uuid,
230        'type' => $type,
231    );
232
233    if ( ! empty( $attributes ) ) {
234        $widget['attributes'] = $attributes;
235    }
236
237    $widget['placement'] = array(
238        'width'  => $width,
239        'height' => $height,
240        'order'  => $order,
241    );
242
243    return $widget;
244}
245
246/**
247 * Returns the bundled default widget layouts keyed by dashboard tab.
248 *
249 * @return array Map of tab IDs to widget layout arrays.
250 */
251function get_dashboard_default_section_layouts() {
252    return array(
253        DASHBOARD_TRAFFIC_SECTION_ID     => array(
254            get_dashboard_default_widget_instance(
255                'default-traffic-chart-widget-instance',
256                'jpa/traffic-chart',
257                0,
258                2,
259                2,
260                array(
261                    'granularity' => 'auto',
262                )
263            ),
264            get_dashboard_default_widget_instance(
265                'default-stats-top-posts-widget-instance',
266                'jpa/stats-top-posts',
267                1,
268                1,
269                2,
270                array(
271                    'num' => 10,
272                )
273            ),
274            get_dashboard_default_widget_instance(
275                'default-referrers-widget-instance',
276                'jpa/referrers',
277                2,
278                1,
279                2,
280                array(
281                    'max' => 10,
282                )
283            ),
284            get_dashboard_default_widget_instance(
285                'default-locations-widget-instance',
286                'jpa/locations',
287                3,
288                2,
289                2,
290                array(
291                    'max' => 10,
292                )
293            ),
294            get_dashboard_default_widget_instance(
295                'default-devices-widget-instance',
296                'jpa/devices',
297                4,
298                1,
299                2,
300                array(
301                    'max' => 5,
302                )
303            ),
304            get_dashboard_default_widget_instance(
305                'default-top-platforms-widget-instance',
306                'jpa/top-platforms',
307                5,
308                1,
309                2,
310                array(
311                    'max' => 10,
312                )
313            ),
314            get_dashboard_default_widget_instance(
315                'default-search-terms-widget-instance',
316                'jpa/search-terms',
317                6,
318                1,
319                2,
320                array(
321                    'max' => 10,
322                )
323            ),
324            get_dashboard_default_widget_instance(
325                'default-utm-insights-widget-instance',
326                'jpa/utm-insights',
327                7,
328                1,
329                2,
330                array(
331                    'utmDimension' => 'utm_source,utm_medium',
332                    'max'          => 10,
333                )
334            ),
335            get_dashboard_default_widget_instance(
336                'default-file-downloads-widget-instance',
337                'jpa/file-downloads',
338                8,
339                1,
340                2,
341                array(
342                    'max' => 10,
343                )
344            ),
345            get_dashboard_default_widget_instance(
346                'default-clicks-widget-instance',
347                'jpa/clicks',
348                9,
349                1,
350                2,
351                array(
352                    'max' => 10,
353                )
354            ),
355        ),
356        DASHBOARD_INSIGHTS_SECTION_ID    => array(
357            get_dashboard_default_widget_instance(
358                'default-annual-highlights-widget-instance',
359                'jpa/annual-highlights',
360                0,
361                2,
362                1
363            ),
364            get_dashboard_default_widget_instance(
365                'default-all-time-stats-widget-instance',
366                'jpa/all-time-stats',
367                1,
368                2,
369                1
370            ),
371            get_dashboard_default_widget_instance(
372                'default-latest-post-widget-instance',
373                'jpa/latest-post',
374                2,
375                1,
376                1
377            ),
378            get_dashboard_default_widget_instance(
379                'default-posting-activity-widget-instance',
380                'jpa/posting-activity',
381                3,
382                2,
383                2
384            ),
385            get_dashboard_default_widget_instance(
386                'default-authors-widget-instance',
387                'jpa/authors',
388                4,
389                1,
390                2,
391                array(
392                    'max' => 7,
393                )
394            ),
395            get_dashboard_default_widget_instance(
396                'default-videos-widget-instance',
397                'jpa/videos',
398                5,
399                1,
400                2,
401                array(
402                    'max' => 7,
403                )
404            ),
405            get_dashboard_default_widget_instance(
406                'default-emails-widget-instance',
407                'jpa/stats-emails',
408                6,
409                1,
410                2,
411                array(
412                    'max'    => 10,
413                    'metric' => 'opens',
414                )
415            ),
416            get_dashboard_default_widget_instance(
417                'default-most-popular-day-widget-instance',
418                'jpa/most-popular-day',
419                7,
420                1,
421                1
422            ),
423            get_dashboard_default_widget_instance(
424                'default-most-popular-time-widget-instance',
425                'jpa/most-popular-time',
426                8,
427                1,
428                1
429            ),
430        ),
431        DASHBOARD_SUBSCRIBERS_SECTION_ID => array(
432            get_dashboard_default_widget_instance(
433                'default-subscriber-highlights-widget-instance',
434                'jpa/subscriber-highlights',
435                0,
436                2,
437                1,
438                array(
439                    'showTotal'  => true,
440                    'showPaid'   => true,
441                    'showFree'   => true,
442                    'showSocial' => true,
443                )
444            ),
445            get_dashboard_default_widget_instance(
446                'default-subscribers-chart-widget-instance',
447                'jpa/subscribers-chart',
448                1,
449                2,
450                2,
451                array(
452                    'granularity' => 'auto',
453                )
454            ),
455            get_dashboard_default_widget_instance(
456                'default-subscribers-list-widget-instance',
457                'jpa/subscribers-list',
458                2,
459                1,
460                2,
461                array(
462                    'num' => 6,
463                )
464            ),
465        ),
466        DASHBOARD_STORE_SECTION_ID       => array(
467            get_dashboard_default_widget_instance(
468                'default-store-performance-widget-instance',
469                'jpa/store-performance',
470                0,
471                2,
472                1
473            ),
474            get_dashboard_default_widget_instance(
475                'default-total-sales-over-time-widget-instance',
476                'jpa/total-sales-over-time',
477                1,
478                1,
479                1
480            ),
481            get_dashboard_default_widget_instance(
482                'default-conversion-rate-widget-instance',
483                'jpa/conversion-rate',
484                2,
485                1,
486                1
487            ),
488            get_dashboard_default_widget_instance(
489                'default-orders-over-time-widget-instance',
490                'jpa/orders-over-time',
491                3,
492                1,
493                1
494            ),
495            get_dashboard_default_widget_instance(
496                'default-average-order-value-widget-instance',
497                'jpa/average-order-value',
498                4,
499                1,
500                1
501            ),
502            get_dashboard_default_widget_instance(
503                'default-top-performing-products-widget-instance',
504                'jpa/top-performing-products',
505                5,
506                1,
507                1
508            ),
509            get_dashboard_default_widget_instance(
510                'default-new-vs-returning-customer-widget-instance',
511                'jpa/new-vs-returning-customer',
512                6,
513                1,
514                1
515            ),
516            get_dashboard_default_widget_instance(
517                'default-payment-status-widget-instance',
518                'jpa/payment-status',
519                7,
520                1,
521                1
522            ),
523            get_dashboard_default_widget_instance(
524                'default-orders-fulfillment-widget-instance',
525                'jpa/orders-fulfillment',
526                8,
527                1,
528                1
529            ),
530        ),
531    );
532}
533
534/**
535 * Resolves a dashboard or section identifier to one of the bundled tab IDs.
536 *
537 * @param string $dashboard_name Dashboard or section identifier.
538 * @return string|null Bundled tab ID, or null when the identifier is unsupported.
539 */
540function get_dashboard_default_section_id_for( $dashboard_name ) {
541    $aliases = array(
542        DASHBOARD_NAME                   => DASHBOARD_TRAFFIC_SECTION_ID,
543        DASHBOARD_TRAFFIC_SECTION_ID     => DASHBOARD_TRAFFIC_SECTION_ID,
544        'analytics/traffic'              => DASHBOARD_TRAFFIC_SECTION_ID,
545        DASHBOARD_INSIGHTS_SECTION_ID    => DASHBOARD_INSIGHTS_SECTION_ID,
546        'analytics/insights'             => DASHBOARD_INSIGHTS_SECTION_ID,
547        DASHBOARD_SUBSCRIBERS_SECTION_ID => DASHBOARD_SUBSCRIBERS_SECTION_ID,
548        'analytics/subscribers'          => DASHBOARD_SUBSCRIBERS_SECTION_ID,
549        DASHBOARD_STORE_SECTION_ID       => DASHBOARD_STORE_SECTION_ID,
550        'woocommerce/store'              => DASHBOARD_STORE_SECTION_ID,
551    );
552
553    return $aliases[ $dashboard_name ] ?? null;
554}
555
556/**
557 * Seeds the bundled default layouts for the Premium Analytics dashboard tabs.
558 *
559 * Only contributes to the known Premium Analytics dashboard and tab aliases;
560 * other dashboards are left untouched so the filter can be reused if more
561 * dashboards are added later.
562 *
563 * @param array  $dashboard_layout Default layout from earlier callbacks.
564 * @param string $dashboard_name   Identifier of the dashboard receiving the
565 *                                 default.
566 * @return array The layout extended with the bundled widget instances.
567 */
568function seed_default_dashboard_layout( $dashboard_layout, $dashboard_name = '' ) {
569    $section_id = get_dashboard_default_section_id_for( $dashboard_name );
570
571    if ( null === $section_id ) {
572        return $dashboard_layout;
573    }
574
575    $uuids   = array_column( $dashboard_layout, 'uuid' );
576    $layouts = get_dashboard_default_section_layouts();
577
578    foreach ( $layouts[ $section_id ] as $widget ) {
579        if ( ! in_array( $widget['uuid'], $uuids, true ) ) {
580            $dashboard_layout[] = $widget;
581            $uuids[]            = $widget['uuid'];
582        }
583    }
584
585    return $dashboard_layout;
586}
587add_filter( DASHBOARD_DEFAULT_LAYOUT_FILTER, __NAMESPACE__ . '\\seed_default_dashboard_layout', 10, 2 );