Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.61% covered (warning)
88.61%
70 / 79
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Analytics_Dashboard
88.61% covered (warning)
88.61%
70 / 79
60.00% covered (warning)
60.00%
6 / 10
23.78
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 register_section
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 get_default_layout
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 remove_held_back_widget_types
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 register_widget_types
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 supports_widget_contract
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 load_build
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 build_dir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 section_slug_is_taken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 dashboard_has_section_slug
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
1<?php
2/**
3 * The WooCommerce section of the Premium Analytics dashboard.
4 *
5 * @package automattic/jetpack-woocommerce-stats
6 */
7
8namespace Automattic\Jetpack\WooCommerceStats;
9
10use function Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_widget_instance;
11use function Automattic\Jetpack\PremiumAnalytics\register_dashboard_section;
12use function Automattic\Jetpack\PremiumAnalytics\register_widget_types_from_manifest;
13use const Automattic\Jetpack\PremiumAnalytics\DASHBOARD_NAME;
14
15/**
16 * Registers the WooCommerce section and the package's widget types.
17 *
18 * The package decides nothing about who gets them: the plugin that bundles it calls `init()`, and
19 * both register when the dashboard's registries hydrate.
20 *
21 * @since 0.1.0-alpha
22 */
23class Analytics_Dashboard {
24
25    const PACKAGE_VERSION = '0.1.0-alpha';
26
27    /**
28     * Namespaced section identifier. Its slug, `woocommerce`, keys the section's URL and stored layouts.
29     */
30    const SECTION_ID = 'woocommerce-analytics/woocommerce';
31
32    /**
33     * Widget type names the package builds, from `widgets/*\/widget.json`.
34     */
35    const NET_SALES_OVER_TIME_TYPE       = 'woocommerce-analytics/net-sales-over-time';
36    const TOTAL_SALES_OVER_TIME_TYPE     = 'woocommerce-analytics/total-sales-over-time';
37    const GROSS_SALES_OVER_TIME_TYPE     = 'woocommerce-analytics/gross-sales-over-time';
38    const ORDERS_OVER_TIME_TYPE          = 'woocommerce-analytics/orders-over-time';
39    const AVERAGE_ORDER_VALUE_TYPE       = 'woocommerce-analytics/average-order-value';
40    const AVERAGE_ITEMS_PER_ORDER_TYPE   = 'woocommerce-analytics/average-items-per-order';
41    const BOOKINGS_OVER_TIME_TYPE        = 'woocommerce-analytics/bookings-over-time';
42    const VISITORS_OVER_TIME_TYPE        = 'woocommerce-analytics/visitors-over-time';
43    const NEW_VS_RETURNING_CUSTOMER_TYPE = 'woocommerce-analytics/new-vs-returning-customer';
44    const PAYMENT_STATUS_TYPE            = 'woocommerce-analytics/payment-status';
45    const ORDERS_FULFILLMENT_TYPE        = 'woocommerce-analytics/orders-fulfillment';
46    const COUPON_USAGE_OVER_TIME_TYPE    = 'woocommerce-analytics/coupon-usage-over-time';
47    const BOOKINGS_BY_STATUS_TYPE        = 'woocommerce-analytics/bookings-by-status';
48    const TOP_PERFORMING_PRODUCTS_TYPE   = 'woocommerce-analytics/top-performing-products';
49    const TOP_PERFORMING_BOOKINGS_TYPE   = 'woocommerce-analytics/top-performing-bookings';
50    const SALES_BY_UTM_SOURCE_TYPE       = 'woocommerce-analytics/sales-by-utm-source';
51    const SALES_BY_UTM_CHANNEL_TYPE      = 'woocommerce-analytics/sales-by-utm-channel';
52    const SALES_BY_UTM_CAMPAIGN_TYPE     = 'woocommerce-analytics/sales-by-utm-campaign';
53
54    /**
55     * Registry actions of the dashboard package.
56     */
57    const REGISTER_SECTIONS_ACTION     = 'jetpack_premium_analytics_register_dashboard_sections';
58    const REGISTER_WIDGET_TYPES_ACTION = 'jetpack_premium_analytics_register_widget_types';
59
60    /**
61     * Text domain of the widget metadata and of the built widget bundles.
62     */
63    const TEXTDOMAIN = 'jetpack-woocommerce-stats-pkg';
64
65    /**
66     * Lowest widget contract the build works against: the UTM leaderboards draw the bars variant,
67     * which the dashboard ships from 1.7.0.
68     */
69    const MIN_WIDGET_API_VERSION = '1.7.0';
70
71    /**
72     * Widget categories the package builds but holds back from the tab for now: the bookings
73     * reports answer 500 on a store without a synced booking (WOOA7S-2287).
74     */
75    const HELD_BACK_WIDGET_CATEGORIES = array( 'bookings' );
76
77    /**
78     * Hook both registrants on the dashboard's registry actions, the reports proxy on REST
79     * requests, and the store currency on the script data.
80     *
81     * Priority 20, after the dashboard package's own registrants: an older package that still
82     * registers the section itself is found by slug and left alone.
83     *
84     * @return void
85     */
86    public static function init() {
87        add_action( self::REGISTER_SECTIONS_ACTION, array( __CLASS__, 'register_section' ), 20 );
88        add_action( self::REGISTER_WIDGET_TYPES_ACTION, array( __CLASS__, 'register_widget_types' ), 20 );
89
90        add_action( 'rest_api_init', array( Api_Proxy_Controller::class, 'init' ) );
91        add_filter( 'jetpack_stats_transient_cleanup_prefixes', array( Api_Proxy_Controller::class, 'register_transient_cleanup_prefix' ) );
92
93        Store_Currency::init();
94    }
95
96    /**
97     * Register the WooCommerce section unless `woocommerce` or the older `store` slug is taken.
98     *
99     * @param object $registry The section registry being hydrated.
100     * @return void
101     */
102    public static function register_section( $registry ) {
103        if ( self::section_slug_is_taken( $registry ) ) {
104            return;
105        }
106
107        $is_available = function_exists( 'Automattic\\Jetpack\\PremiumAnalytics\\is_store_dashboard_section_available' )
108            ? 'Automattic\\Jetpack\\PremiumAnalytics\\is_store_dashboard_section_available'
109            : '__return_false';
110
111        register_dashboard_section(
112            DASHBOARD_NAME,
113            self::SECTION_ID,
114            array(
115                'label'          => __( 'WooCommerce', 'jetpack-woocommerce-stats-pkg' ),
116                'order'          => 40,
117                'is_available'   => $is_available,
118                // Nothing backfills historical orders to WordPress.com but the analytics full sync.
119                'requires_sync'  => true,
120                'default_layout' => array( __CLASS__, 'get_default_layout' ),
121            )
122        );
123    }
124
125    /**
126     * The default layout of the WooCommerce tab, which is also what the inserter offers there.
127     *
128     * A new composition, built up as the widgets land in the package.
129     *
130     * @return array[] Widget instances, as `get_dashboard_default_widget_instance()` builds them.
131     */
132    public static function get_default_layout() {
133        return array(
134            get_dashboard_default_widget_instance( 'default-net-sales-over-time-widget-instance', self::NET_SALES_OVER_TIME_TYPE, 0, 1, 2 ),
135            get_dashboard_default_widget_instance( 'default-total-sales-over-time-widget-instance', self::TOTAL_SALES_OVER_TIME_TYPE, 1, 1, 2 ),
136            get_dashboard_default_widget_instance( 'default-gross-sales-over-time-widget-instance', self::GROSS_SALES_OVER_TIME_TYPE, 2, 1, 2 ),
137            get_dashboard_default_widget_instance( 'default-orders-over-time-widget-instance', self::ORDERS_OVER_TIME_TYPE, 3, 1, 2 ),
138            get_dashboard_default_widget_instance( 'default-average-order-value-widget-instance', self::AVERAGE_ORDER_VALUE_TYPE, 4, 1, 2 ),
139            get_dashboard_default_widget_instance( 'default-average-items-per-order-widget-instance', self::AVERAGE_ITEMS_PER_ORDER_TYPE, 5, 1, 2 ),
140            get_dashboard_default_widget_instance( 'default-visitors-over-time-widget-instance', self::VISITORS_OVER_TIME_TYPE, 6, 1, 2 ),
141            get_dashboard_default_widget_instance( 'default-new-vs-returning-customer-widget-instance', self::NEW_VS_RETURNING_CUSTOMER_TYPE, 7, 1, 2 ),
142            get_dashboard_default_widget_instance( 'default-payment-status-widget-instance', self::PAYMENT_STATUS_TYPE, 8, 1, 2 ),
143            get_dashboard_default_widget_instance( 'default-orders-fulfillment-widget-instance', self::ORDERS_FULFILLMENT_TYPE, 9, 1, 2 ),
144            get_dashboard_default_widget_instance( 'default-coupon-usage-over-time-widget-instance', self::COUPON_USAGE_OVER_TIME_TYPE, 10, 1, 2 ),
145            get_dashboard_default_widget_instance( 'default-top-performing-products-widget-instance', self::TOP_PERFORMING_PRODUCTS_TYPE, 11, 1, 2 ),
146            get_dashboard_default_widget_instance( 'default-sales-by-utm-source-widget-instance', self::SALES_BY_UTM_SOURCE_TYPE, 12, 1, 2 ),
147            get_dashboard_default_widget_instance( 'default-sales-by-utm-channel-widget-instance', self::SALES_BY_UTM_CHANNEL_TYPE, 13, 1, 2 ),
148            get_dashboard_default_widget_instance( 'default-sales-by-utm-campaign-widget-instance', self::SALES_BY_UTM_CAMPAIGN_TYPE, 14, 1, 2 ),
149        );
150    }
151
152    /**
153     * The manifest minus the categories held back from the tab.
154     *
155     * @param array[] $widget_modules Widget module records of the build manifest.
156     * @return array[] The records the package registers.
157     */
158    private static function remove_held_back_widget_types( $widget_modules ) {
159        return array_values(
160            array_filter(
161                $widget_modules,
162                static function ( $widget_module ) {
163                    return ! in_array( $widget_module['category'] ?? '', self::HELD_BACK_WIDGET_CATEGORIES, true );
164                }
165            )
166        );
167    }
168
169    /**
170     * Register the widget types from the package's build manifest.
171     *
172     * Loads the generated build first: after `init` it registers the widget script modules on the
173     * spot, so they reach the page import map the same request.
174     *
175     * @param object $registry The widget type registry being hydrated.
176     * @return void
177     */
178    public static function register_widget_types( $registry ) {
179        if ( ! self::supports_widget_contract() ) {
180            return;
181        }
182
183        self::load_build();
184        if ( ! function_exists( 'jetpack_woocommerce_stats_get_registered_widget_modules' ) ) {
185            return;
186        }
187
188        register_widget_types_from_manifest(
189            self::remove_held_back_widget_types( jetpack_woocommerce_stats_get_registered_widget_modules() ),
190            array(
191                'textdomain'    => self::TEXTDOMAIN,
192                'i18n_manifest' => add_query_arg( 'ver', self::PACKAGE_VERSION, plugins_url( 'i18n-manifest.json', self::build_dir() . '/build.php' ) ),
193            ),
194            $registry
195        );
196    }
197
198    /**
199     * Whether the dashboard's widget contract is one the widgets were built against.
200     *
201     * On an older contract the SDK module lacks a name the widgets import, and they fail to load.
202     *
203     * @return bool
204     */
205    private static function supports_widget_contract() {
206        if ( ! defined( 'Automattic\\Jetpack\\PremiumAnalytics\\WIDGET_API_VERSION' ) ) {
207            return false;
208        }
209
210        $version = \Automattic\Jetpack\PremiumAnalytics\WIDGET_API_VERSION;
211
212        return version_compare( $version, self::MIN_WIDGET_API_VERSION, '>=' ) && version_compare( $version, '2', '<' );
213    }
214
215    /**
216     * Load the generated build once. Guarded by symbol, not by path: two copies of the package
217     * can share a request.
218     *
219     * @return void
220     */
221    private static function load_build() {
222        if ( function_exists( 'jetpack_woocommerce_stats_get_registered_widget_modules' ) ) {
223            return;
224        }
225        $loader = self::build_dir() . '/build.php';
226        if ( file_exists( $loader ) ) {
227            require_once $loader;
228        }
229    }
230
231    /**
232     * Directory of the wp-build output.
233     *
234     * @return string
235     */
236    private static function build_dir() {
237        return dirname( __DIR__ ) . '/build';
238    }
239
240    /**
241     * Whether this tab's slug, or the `store` slug an older package still registers, is taken.
242     *
243     * @param object $registry The section registry being hydrated.
244     * @return bool
245     */
246    private static function section_slug_is_taken( $registry ) {
247        foreach ( array( 'woocommerce', 'store' ) as $slug ) {
248            if ( self::dashboard_has_section_slug( $registry, DASHBOARD_NAME, $slug ) ) {
249                return true;
250            }
251        }
252
253        return false;
254    }
255
256    /**
257     * Whether a section with the slug is registered on the dashboard.
258     *
259     * Reads the registry through the slug lookup when the package offers it, and through the full
260     * list otherwise: the package and this one ship on different cadences.
261     *
262     * @param object $registry       The registry being hydrated.
263     * @param string $dashboard_name Dashboard identifier.
264     * @param string $slug           Section slug.
265     * @return bool
266     */
267    private static function dashboard_has_section_slug( $registry, $dashboard_name, $slug ) {
268        if ( method_exists( $registry, 'get_registered_by_slug' ) ) {
269            return null !== $registry->get_registered_by_slug( $dashboard_name, $slug );
270        }
271
272        foreach ( $registry->get_all_registered( $dashboard_name ) as $section ) {
273            if ( $section->slug === $slug ) {
274                return true;
275            }
276        }
277
278        return false;
279    }
280}