Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.87% covered (success)
98.87%
350 / 354
100.00% covered (success)
100.00%
8 / 8
CRAP
n/a
0 / 0
Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_layout_for
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_layout_gates
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_layout_response
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\register_dashboard_default_layout_route
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
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%
281 / 281
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). The default is served on the
8 * `dashboardSection` REST shape and a per-dashboard `default-layout` route; the
9 * frontend reads it from the section entity, so there is no server-seeded
10 * preference.
11 *
12 * @package automattic/jetpack-premium-analytics
13 */
14
15namespace Automattic\Jetpack\PremiumAnalytics;
16
17require_once __DIR__ . '/dashboard-grammar.php';
18require_once __DIR__ . '/rest-namespace.php';
19// Availability policy for default layout instances: defaults are read outside
20// the widget registry bootstrap, so the policy must be loaded here explicitly.
21require_once __DIR__ . '/widget-type-support.php';
22
23/**
24 * Identifier of the Premium Analytics dashboard, formatted as `<plugin>_<page>`
25 * to match the underscore form produced by the wp-build pipeline. Used as the
26 * `{name}` segment of the REST route.
27 */
28const DASHBOARD_NAME = 'jetpack-premium-analytics_dashboard';
29
30/**
31 * Filter through which the default layout for a dashboard is resolved.
32 */
33const DASHBOARD_DEFAULT_LAYOUT_FILTER = 'jetpack_premium_analytics_dashboard_default_layout';
34
35/**
36 * Preference keys used by the dashboard route's tabbed sections.
37 */
38const DASHBOARD_TRAFFIC_SECTION_ID     = 'traffic';
39const DASHBOARD_INSIGHTS_SECTION_ID    = 'insights';
40const DASHBOARD_SUBSCRIBERS_SECTION_ID = 'subscribers';
41const DASHBOARD_STORE_SECTION_ID       = 'store';
42
43/**
44 * Resolves the default layout registered for a dashboard.
45 *
46 * Returns a fresh evaluation of the filter chain each call, so callers always
47 * see the current code default rather than a hydrated copy.
48 *
49 * @param string $dashboard_name Identifier of the dashboard or dashboard section.
50 * @return array Array of widget instances (possibly empty).
51 */
52function get_dashboard_default_layout_for( $dashboard_name ) {
53    /**
54     * Filters the default dashboard layout served to users who have not
55     * customized theirs.
56     *
57     * Each entry should match the dashboard's widget instance shape: `uuid`,
58     * `type`, optional `attributes`, optional `placement`.
59     *
60     * @param array  $default_layout Default array of widget instances.
61     * @param string $dashboard_name Identifier of the dashboard or dashboard
62     *                               section receiving the default. Callbacks
63     *                               targeting a specific default should switch
64     *                               on this value.
65     */
66    $default = apply_filters( DASHBOARD_DEFAULT_LAYOUT_FILTER, array(), $dashboard_name );
67
68    return remove_unsupported_widget_items(
69        is_array( $default ) ? array_values( $default ) : array(),
70        'type',
71        get_widget_support_context()
72    );
73}
74
75/**
76 * Returns availability gates for conditional dashboard tabs.
77 *
78 * The callbacks are used directly because the section registry may not be
79 * initialized when this route runs.
80 *
81 * @since 0.3.0
82 *
83 * @return callable[] Resolved tab ID mapped to its availability callback.
84 */
85function get_dashboard_default_layout_gates() {
86    return array(
87        DASHBOARD_STORE_SECTION_ID       => array( Capabilities::class, 'current_user_can_view_store_reports' ),
88        DASHBOARD_SUBSCRIBERS_SECTION_ID => __NAMESPACE__ . '\\is_subscribers_dashboard_section_available',
89    );
90}
91
92/**
93 * REST callback returning the default layout for the requested dashboard.
94 *
95 * Availability is checked after resolving the name because tabs can be
96 * requested by alias or full section ID.
97 *
98 * @param \WP_REST_Request $request REST request carrying the dashboard name.
99 * @return \WP_REST_Response|\WP_Error Response wrapping the default layout array.
100 */
101function get_dashboard_default_layout_response( $request ) {
102    $dashboard_name = $request['name'];
103    $gates          = get_dashboard_default_layout_gates();
104    $section_id     = get_dashboard_default_section_id_for( $dashboard_name );
105
106    if ( isset( $gates[ $section_id ] ) && ! call_user_func( $gates[ $section_id ] ) ) {
107        return new \WP_Error(
108            'dashboard_section_unavailable',
109            __( 'Dashboard section is not available.', 'jetpack-premium-analytics-pkg' ),
110            array( 'status' => 404 )
111        );
112    }
113
114    return rest_ensure_response( get_dashboard_default_layout_for( $dashboard_name ) );
115}
116
117/**
118 * Registers the REST route that exposes per-dashboard default layouts.
119 *
120 * @return void
121 */
122function register_dashboard_default_layout_route() {
123    register_rest_route(
124        DASHBOARD_REST_NAMESPACE,
125        '/dashboards/(?P<name>' . get_dashboard_name_pattern() . ')/default-layout',
126        array(
127            'methods'             => \WP_REST_Server::READABLE,
128            'callback'            => __NAMESPACE__ . '\\get_dashboard_default_layout_response',
129            'permission_callback' => array( Capabilities::class, 'current_user_can_view_analytics' ),
130            'args'                => array(
131                'name' => array(
132                    'description' => __( 'Dashboard identifier as produced by the build pipeline.', 'jetpack-premium-analytics-pkg' ),
133                    'type'        => 'string',
134                ),
135            ),
136        )
137    );
138}
139
140/**
141 * Builds a widget instance for bundled dashboard defaults.
142 *
143 * @param string $uuid       Widget instance UUID.
144 * @param string $type       Widget type.
145 * @param int    $order      Widget placement order.
146 * @param int    $width      Widget placement width.
147 * @param int    $height     Widget placement height.
148 * @param array  $attributes Optional widget attributes.
149 * @return array Widget instance.
150 */
151function get_dashboard_default_widget_instance(
152    $uuid,
153    $type,
154    $order,
155    $width = 1,
156    $height = 1,
157    $attributes = array()
158) {
159    $widget = array(
160        'uuid' => $uuid,
161        'type' => $type,
162    );
163
164    if ( ! empty( $attributes ) ) {
165        $widget['attributes'] = $attributes;
166    }
167
168    $widget['placement'] = array(
169        'width'  => $width,
170        'height' => $height,
171        'order'  => $order,
172    );
173
174    return $widget;
175}
176
177/**
178 * Returns the bundled default widget layouts keyed by dashboard tab.
179 *
180 * @return array Map of tab IDs to widget layout arrays.
181 */
182function get_dashboard_default_section_layouts() {
183    return array(
184        DASHBOARD_TRAFFIC_SECTION_ID     => array(
185            // Rows fill the four-column grid. Plan usage is intentionally not a
186            // default; it stays available from the widget picker.
187            // Row 1: traffic chart.
188            get_dashboard_default_widget_instance(
189                'default-traffic-chart-widget-instance',
190                'jpa/traffic-chart',
191                0,
192                4,
193                2,
194                array(
195                    'granularity' => 'auto',
196                )
197            ),
198            // Row 2: most-viewed posts + referrers + devices.
199            get_dashboard_default_widget_instance(
200                'default-stats-top-posts-widget-instance',
201                'jpa/stats-top-posts',
202                1,
203                2,
204                2
205            ),
206            get_dashboard_default_widget_instance(
207                'default-referrers-widget-instance',
208                'jpa/referrers',
209                2,
210                1,
211                2
212            ),
213            get_dashboard_default_widget_instance(
214                'default-devices-widget-instance',
215                'jpa/devices',
216                3,
217                1,
218                2
219            ),
220            // Row 3: locations map + top platforms.
221            get_dashboard_default_widget_instance(
222                'default-locations-widget-instance',
223                'jpa/locations',
224                4,
225                3,
226                2
227            ),
228            get_dashboard_default_widget_instance(
229                'default-top-platforms-widget-instance',
230                'jpa/top-platforms',
231                5,
232                1,
233                2
234            ),
235            // Row 4: VideoPress (sites running VideoPress only) + clicks + authors.
236            get_dashboard_default_widget_instance(
237                'default-videopress-widget-instance',
238                'jpa/videopress',
239                6,
240                1,
241                2
242            ),
243            get_dashboard_default_widget_instance(
244                'default-clicks-widget-instance',
245                'jpa/clicks',
246                7,
247                1,
248                2
249            ),
250            get_dashboard_default_widget_instance(
251                'default-authors-widget-instance',
252                'jpa/authors',
253                8,
254                2,
255                2
256            ),
257            // Row 5: UTM insights + search terms + file downloads (Simple only).
258            get_dashboard_default_widget_instance(
259                'default-utm-insights-widget-instance',
260                'jpa/utm-insights',
261                9,
262                2,
263                2,
264                array(
265                    'utmDimension' => 'utm_source,utm_medium',
266                )
267            ),
268            get_dashboard_default_widget_instance(
269                'default-search-terms-widget-instance',
270                'jpa/search-terms',
271                10,
272                1,
273                2
274            ),
275            get_dashboard_default_widget_instance(
276                'default-file-downloads-widget-instance',
277                'jpa/file-downloads',
278                11,
279                1,
280                2
281            ),
282        ),
283        DASHBOARD_INSIGHTS_SECTION_ID    => array(
284            // Follows the prototype's rows (WOOA7S-1786). Emails lives on the
285            // Subscribers tab, so that row is absent.
286            // Row 1: highlights banner.
287            get_dashboard_default_widget_instance(
288                'default-annual-highlights-widget-instance',
289                'jpa/annual-highlights',
290                0,
291                4,
292                1
293            ),
294            // Row 2: posting-activity heatmap.
295            get_dashboard_default_widget_instance(
296                'default-posting-activity-widget-instance',
297                'jpa/posting-activity',
298                1,
299                4,
300                1
301            ),
302            // Row 3: the two post spotlights.
303            get_dashboard_default_widget_instance(
304                'default-latest-post-widget-instance',
305                'jpa/latest-post',
306                2,
307                2,
308                2
309            ),
310            get_dashboard_default_widget_instance(
311                'default-popular-post-widget-instance',
312                'jpa/popular-post',
313                3,
314                2,
315                2
316            ),
317            // Row 4: the period totals and the weekday and hour-of-day
318            // distributions.
319            get_dashboard_default_widget_instance(
320                'default-total-views-widget-instance',
321                'jpa/total-views',
322                4,
323                1,
324                1
325            ),
326            get_dashboard_default_widget_instance(
327                'default-total-visitors-widget-instance',
328                'jpa/total-visitors',
329                5,
330                1,
331                1
332            ),
333            get_dashboard_default_widget_instance(
334                'default-popular-days-widget-instance',
335                'jpa/popular-days',
336                6,
337                1,
338                1
339            ),
340            get_dashboard_default_widget_instance(
341                'default-popular-hours-widget-instance',
342                'jpa/popular-hours',
343                7,
344                1,
345                1
346            ),
347            // Row 5: daily views heatmap. Two rows tall, as in the prototype: the
348            // cells are sized from the tile's height, and only at this height do they
349            // grow wide enough to label each day with its view count.
350            get_dashboard_default_widget_instance(
351                'default-traffic-views-activity-widget-instance',
352                'jpa/traffic-views-activity',
353                8,
354                4,
355                2
356            ),
357            // Row 6: the comment leaderboards, shares, and tags.
358            get_dashboard_default_widget_instance(
359                'default-most-commented-posts-widget-instance',
360                'jpa/most-commented-posts',
361                9,
362                1,
363                2
364            ),
365            get_dashboard_default_widget_instance(
366                'default-most-commented-authors-widget-instance',
367                'jpa/most-commented-authors',
368                10,
369                1,
370                2
371            ),
372            get_dashboard_default_widget_instance(
373                'default-shares-widget-instance',
374                'jpa/shares',
375                11,
376                1,
377                2
378            ),
379            get_dashboard_default_widget_instance(
380                'default-tags-widget-instance',
381                'jpa/tags',
382                12,
383                1,
384                2
385            ),
386        ),
387        DASHBOARD_SUBSCRIBERS_SECTION_ID => array(
388            // Subscriber highlights is intentionally not a default: the design
389            // opens on the chart. It stays available from the widget picker.
390            // Row 1: subscribers chart.
391            get_dashboard_default_widget_instance(
392                'default-subscribers-chart-widget-instance',
393                'jpa/subscribers-chart',
394                0,
395                4,
396                2,
397                array(
398                    'granularity' => 'auto',
399                )
400            ),
401            // Row 2: latest subscribers + latest emails sent.
402            get_dashboard_default_widget_instance(
403                'default-subscribers-list-widget-instance',
404                'jpa/subscribers-list',
405                1,
406                2,
407                2
408            ),
409            get_dashboard_default_widget_instance(
410                'default-subscribers-emails-widget-instance',
411                'jpa/stats-emails',
412                2,
413                2,
414                2,
415                array(
416                    'metric' => 'opens',
417                )
418            ),
419        ),
420        DASHBOARD_STORE_SECTION_ID       => array(
421            get_dashboard_default_widget_instance(
422                'default-store-performance-widget-instance',
423                'jpa/store-performance',
424                0,
425                2,
426                1
427            ),
428            get_dashboard_default_widget_instance(
429                'default-total-sales-over-time-widget-instance',
430                'jpa/total-sales-over-time',
431                1,
432                1,
433                1
434            ),
435            get_dashboard_default_widget_instance(
436                'default-conversion-rate-widget-instance',
437                'jpa/conversion-rate',
438                2,
439                1,
440                1
441            ),
442            get_dashboard_default_widget_instance(
443                'default-orders-over-time-widget-instance',
444                'jpa/orders-over-time',
445                3,
446                1,
447                1
448            ),
449            get_dashboard_default_widget_instance(
450                'default-average-order-value-widget-instance',
451                'jpa/average-order-value',
452                4,
453                1,
454                1
455            ),
456            get_dashboard_default_widget_instance(
457                'default-top-performing-products-widget-instance',
458                'jpa/top-performing-products',
459                5,
460                1,
461                1
462            ),
463            get_dashboard_default_widget_instance(
464                'default-new-vs-returning-customer-widget-instance',
465                'jpa/new-vs-returning-customer',
466                6,
467                1,
468                1
469            ),
470            get_dashboard_default_widget_instance(
471                'default-payment-status-widget-instance',
472                'jpa/payment-status',
473                7,
474                1,
475                1
476            ),
477            get_dashboard_default_widget_instance(
478                'default-orders-fulfillment-widget-instance',
479                'jpa/orders-fulfillment',
480                8,
481                1,
482                1
483            ),
484        ),
485    );
486}
487
488/**
489 * Resolves a dashboard or section identifier to one of the bundled tab IDs.
490 *
491 * @param string $dashboard_name Dashboard or section identifier.
492 * @return string|null Bundled tab ID, or null when the identifier is unsupported.
493 */
494function get_dashboard_default_section_id_for( $dashboard_name ) {
495    $aliases = array(
496        DASHBOARD_NAME                   => DASHBOARD_TRAFFIC_SECTION_ID,
497        DASHBOARD_TRAFFIC_SECTION_ID     => DASHBOARD_TRAFFIC_SECTION_ID,
498        'analytics/traffic'              => DASHBOARD_TRAFFIC_SECTION_ID,
499        DASHBOARD_INSIGHTS_SECTION_ID    => DASHBOARD_INSIGHTS_SECTION_ID,
500        'analytics/insights'             => DASHBOARD_INSIGHTS_SECTION_ID,
501        DASHBOARD_SUBSCRIBERS_SECTION_ID => DASHBOARD_SUBSCRIBERS_SECTION_ID,
502        'analytics/subscribers'          => DASHBOARD_SUBSCRIBERS_SECTION_ID,
503        DASHBOARD_STORE_SECTION_ID       => DASHBOARD_STORE_SECTION_ID,
504        'woocommerce/store'              => DASHBOARD_STORE_SECTION_ID,
505    );
506
507    return $aliases[ $dashboard_name ] ?? null;
508}
509
510/**
511 * Seeds the bundled default layouts for the Premium Analytics dashboard tabs.
512 *
513 * Only contributes to the known Premium Analytics dashboard and tab aliases;
514 * other dashboards are left untouched so the filter can be reused if more
515 * dashboards are added later.
516 *
517 * @param array  $dashboard_layout Default layout from earlier callbacks.
518 * @param string $dashboard_name   Identifier of the dashboard receiving the
519 *                                 default.
520 * @return array The layout extended with the bundled widget instances.
521 */
522function seed_default_dashboard_layout( $dashboard_layout, $dashboard_name = '' ) {
523    $section_id = get_dashboard_default_section_id_for( $dashboard_name );
524
525    if ( null === $section_id ) {
526        return $dashboard_layout;
527    }
528
529    $uuids   = array_column( $dashboard_layout, 'uuid' );
530    $layouts = get_dashboard_default_section_layouts();
531
532    foreach ( $layouts[ $section_id ] as $widget ) {
533        if ( ! in_array( $widget['uuid'], $uuids, true ) ) {
534            $dashboard_layout[] = $widget;
535            $uuids[]            = $widget['uuid'];
536        }
537    }
538
539    return $dashboard_layout;
540}
541add_filter( DASHBOARD_DEFAULT_LAYOUT_FILTER, __NAMESPACE__ . '\\seed_default_dashboard_layout', 10, 2 );