Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.37% covered (success)
92.37%
109 / 118
76.47% covered (warning)
76.47%
13 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Analytics_Dashboard
92.37% covered (success)
92.37%
109 / 118
76.47% covered (warning)
76.47%
13 / 17
51.11
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 register_section
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 get_no_orders_notice
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 get_default_layout
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_store_performance_bookings
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 remove_unavailable_widget_types
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 register_widget_types
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 is_section_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 is_woocommerce_available
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 current_user_can_view_store_reports
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_store_section_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 is_bookings_plugin_active
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 supports_widget_contract
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 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 Automattic\Jetpack\PremiumAnalytics\Enablement_Setting;
11use function Automattic\Jetpack\PremiumAnalytics\get_dashboard_default_widget_instance;
12use function Automattic\Jetpack\PremiumAnalytics\register_dashboard_section;
13use function Automattic\Jetpack\PremiumAnalytics\register_widget_types_from_manifest;
14use const Automattic\Jetpack\PremiumAnalytics\DASHBOARD_NAME;
15
16/**
17 * Registers the WooCommerce section and the package's widget types.
18 *
19 * The plugin that bundles it calls `init()`; both register when the dashboard's registries hydrate,
20 * gated here on WooCommerce and on the reader's store reports capability.
21 *
22 * @since 0.1.0-alpha
23 */
24class Analytics_Dashboard {
25
26    const PACKAGE_VERSION = '0.1.0';
27
28    /**
29     * Namespaced section identifier. Its slug, `woocommerce`, keys the section's URL and stored layouts.
30     */
31    const SECTION_ID = 'woocommerce-analytics/woocommerce';
32
33    /**
34     * Widget type names the package builds, from `widgets/*\/widget.json`.
35     */
36    const STORE_PERFORMANCE_TYPE         = 'woocommerce-analytics/store-performance';
37    const NET_SALES_OVER_TIME_TYPE       = 'woocommerce-analytics/net-sales-over-time';
38    const TOTAL_SALES_OVER_TIME_TYPE     = 'woocommerce-analytics/total-sales-over-time';
39    const GROSS_SALES_OVER_TIME_TYPE     = 'woocommerce-analytics/gross-sales-over-time';
40    const ORDERS_OVER_TIME_TYPE          = 'woocommerce-analytics/orders-over-time';
41    const AVERAGE_ORDER_VALUE_TYPE       = 'woocommerce-analytics/average-order-value';
42    const AVERAGE_ITEMS_PER_ORDER_TYPE   = 'woocommerce-analytics/average-items-per-order';
43    const BOOKINGS_OVER_TIME_TYPE        = 'woocommerce-analytics/bookings-over-time';
44    const VISITORS_OVER_TIME_TYPE        = 'woocommerce-analytics/visitors-over-time';
45    const NEW_VS_RETURNING_CUSTOMER_TYPE = 'woocommerce-analytics/new-vs-returning-customer';
46    const PAYMENT_STATUS_TYPE            = 'woocommerce-analytics/payment-status';
47    const ORDERS_FULFILLMENT_TYPE        = 'woocommerce-analytics/orders-fulfillment';
48    const COUPON_USAGE_OVER_TIME_TYPE    = 'woocommerce-analytics/coupon-usage-over-time';
49    const BOOKINGS_BY_STATUS_TYPE        = 'woocommerce-analytics/bookings-by-status';
50    const SESSIONS_BY_DEVICE_TYPE        = 'woocommerce-analytics/sessions-by-device';
51
52    /**
53     * The columns family, with the bookings variants of the first two.
54     */
55    const REVENUE_BY_CUSTOMER_TYPE_TYPE          = 'woocommerce-analytics/revenue-by-customer-type';
56    const BOOKINGS_REVENUE_BY_CUSTOMER_TYPE_TYPE = 'woocommerce-analytics/bookings-revenue-by-customer-type';
57    const SALES_BY_DEVICE_TYPE                   = 'woocommerce-analytics/sales-by-device';
58    const BOOKINGS_BY_DEVICE_TYPE                = 'woocommerce-analytics/bookings-by-device';
59    const TOTAL_RETURNS_TYPE                     = 'woocommerce-analytics/total-returns';
60    const SALES_BY_COUPON_TYPE                   = 'woocommerce-analytics/sales-by-coupon';
61    const TOP_PERFORMING_PRODUCTS_TYPE           = 'woocommerce-analytics/top-performing-products';
62    const TOP_PERFORMING_BOOKINGS_TYPE           = 'woocommerce-analytics/top-performing-bookings';
63    const SALES_BY_UTM_SOURCE_TYPE               = 'woocommerce-analytics/sales-by-utm-source';
64    const SALES_BY_UTM_CHANNEL_TYPE              = 'woocommerce-analytics/sales-by-utm-channel';
65    const SALES_BY_UTM_CAMPAIGN_TYPE             = 'woocommerce-analytics/sales-by-utm-campaign';
66    const VISITORS_BY_LOCATION_TYPE              = 'woocommerce-analytics/visitors-by-location';
67
68    /**
69     * The conversion funnel, for every product and for bookings.
70     */
71    const CONVERSION_RATE_TYPE                = 'woocommerce-analytics/conversion-rate';
72    const STORE_CONVERSION_RATE_BOOKINGS_TYPE = 'woocommerce-analytics/store-conversion-rate-bookings';
73
74    /**
75     * Registry actions of the dashboard package.
76     */
77    const REGISTER_SECTIONS_ACTION     = 'jetpack_premium_analytics_register_dashboard_sections';
78    const REGISTER_WIDGET_TYPES_ACTION = 'jetpack_premium_analytics_register_widget_types';
79
80    /**
81     * Text domain of the widget metadata and of the built widget bundles.
82     */
83    const TEXTDOMAIN = 'jetpack-woocommerce-stats-pkg';
84
85    /**
86     * Lowest widget contract the build works against: Visitors by location draws `Locations`,
87     * which the dashboard ships from 1.11.0.
88     */
89    const MIN_WIDGET_API_VERSION = '1.11.0';
90
91    /**
92     * Widget categories the package builds but holds back from the tab for now: the bookings
93     * reports answer 500 on a store without a synced booking (WOOA7S-2287).
94     */
95    const HELD_BACK_WIDGET_CATEGORIES = array( 'bookings' );
96
97    /**
98     * Ahead of Traffic (10), so a store's dashboard opens on this tab.
99     */
100    const SECTION_ORDER = 5;
101
102    /**
103     * Widget types the tab offers in the inserter on top of its default layout: the over-time
104     * charts a store owner may prefer over Store performance.
105     */
106    const INSERTABLE_WIDGET_TYPES = array(
107        self::NET_SALES_OVER_TIME_TYPE,
108        self::TOTAL_SALES_OVER_TIME_TYPE,
109        self::GROSS_SALES_OVER_TIME_TYPE,
110        self::ORDERS_OVER_TIME_TYPE,
111        self::VISITORS_OVER_TIME_TYPE,
112    );
113
114    /**
115     * Filter through which WooCommerce detection is resolved, for hosts and tests without the plugin.
116     */
117    const WOOCOMMERCE_AVAILABLE_FILTER = 'jetpack_woocommerce_stats_woocommerce_available';
118
119    /**
120     * Widget categories that also need the WooCommerce Bookings extension.
121     */
122    const BOOKINGS_WIDGET_CATEGORIES = array( 'bookings' );
123
124    /**
125     * The module that offers the Bookings metric to Store performance.
126     */
127    const STORE_PERFORMANCE_BOOKINGS_MODULE = '@jetpack-woocommerce-stats/store-performance-bookings';
128
129    /**
130     * Action the dashboard page fires as it enqueues its scripts: its menu slug,
131     * `Analytics::MENU_PAGE_SLUG`, under wp-build's `_init` suffix.
132     */
133    const DASHBOARD_PAGE_INIT_ACTION = 'jetpack-premium-analytics-wp-admin_init';
134
135    /**
136     * Hook both registrants on the dashboard's registry actions, the Bookings metric on the dashboard
137     * page, the reports proxy on REST requests, and the store currency on the script data.
138     *
139     * Priority 20, after the dashboard package's own registrants: an older package that still
140     * registers the section itself is found by slug and left alone.
141     *
142     * @return void
143     */
144    public static function init() {
145        add_action( self::REGISTER_SECTIONS_ACTION, array( __CLASS__, 'register_section' ), 20 );
146        add_action( self::REGISTER_WIDGET_TYPES_ACTION, array( __CLASS__, 'register_widget_types' ), 20 );
147        add_action( self::DASHBOARD_PAGE_INIT_ACTION, array( __CLASS__, 'enqueue_store_performance_bookings' ) );
148
149        add_action( 'rest_api_init', array( Api_Proxy_Controller::class, 'init' ) );
150        add_filter( 'jetpack_stats_transient_cleanup_prefixes', array( Api_Proxy_Controller::class, 'register_transient_cleanup_prefix' ) );
151
152        Store_Currency::init();
153    }
154
155    /**
156     * Register the WooCommerce section unless `woocommerce` or the older `store` slug is taken.
157     *
158     * @param object $registry The section registry being hydrated.
159     * @return void
160     */
161    public static function register_section( $registry ) {
162        if ( self::section_slug_is_taken( $registry ) ) {
163            return;
164        }
165
166        register_dashboard_section(
167            DASHBOARD_NAME,
168            self::SECTION_ID,
169            array(
170                'label'                   => __( 'WooCommerce', 'jetpack-woocommerce-stats-pkg' ),
171                'order'                   => self::SECTION_ORDER,
172                'is_available'            => array( __CLASS__, 'is_section_available' ),
173                // Nothing backfills historical orders to WordPress.com but the analytics full sync.
174                'requires_sync'           => true,
175                'notice'                  => array( __CLASS__, 'get_no_orders_notice' ),
176                'default_layout'          => array( __CLASS__, 'get_default_layout' ),
177                'insertable_widget_types' => self::INSERTABLE_WIDGET_TYPES,
178            )
179        );
180    }
181
182    /**
183     * The message above the tab while the store has never had an order.
184     *
185     * @return string|null The message, or null once the store has an order or without WooCommerce.
186     */
187    public static function get_no_orders_notice() {
188        if ( ! function_exists( 'wc_get_orders' ) ) {
189            return null;
190        }
191
192        // The registered statuses, so an abandoned checkout draft is not mistaken for a first order.
193        $orders = wc_get_orders(
194            array(
195                'type'   => 'shop_order',
196                'status' => array_keys( wc_get_order_statuses() ),
197                'limit'  => 1,
198                'return' => 'ids',
199            )
200        );
201
202        if ( ! empty( $orders ) ) {
203            return null;
204        }
205
206        return __( 'Your store has no orders yet. Your sales, orders and customers will show here after your first order.', 'jetpack-woocommerce-stats-pkg' );
207    }
208
209    /**
210     * The default layout of the WooCommerce tab; the inserter offers its types plus
211     * `INSERTABLE_WIDGET_TYPES`.
212     *
213     * Each group below fills one row of the dashboard grid.
214     *
215     * @return array[] Widget instances, as `get_dashboard_default_widget_instance()` builds them.
216     */
217    public static function get_default_layout() {
218        return array(
219            get_dashboard_default_widget_instance( 'default-store-performance-widget-instance', self::STORE_PERFORMANCE_TYPE, 0, 'full', 2 ),
220
221            get_dashboard_default_widget_instance( 'default-top-performing-products-widget-instance', self::TOP_PERFORMING_PRODUCTS_TYPE, 10, 1, 2 ),
222            get_dashboard_default_widget_instance( 'default-average-order-value-widget-instance', self::AVERAGE_ORDER_VALUE_TYPE, 20, 1, 2 ),
223            get_dashboard_default_widget_instance( 'default-average-items-per-order-widget-instance', self::AVERAGE_ITEMS_PER_ORDER_TYPE, 30, 1, 2 ),
224
225            get_dashboard_default_widget_instance( 'default-conversion-rate-widget-instance', self::CONVERSION_RATE_TYPE, 40, 1, 2 ),
226            get_dashboard_default_widget_instance( 'default-visitors-by-location-widget-instance', self::VISITORS_BY_LOCATION_TYPE, 50, 2, 2 ),
227
228            get_dashboard_default_widget_instance( 'default-new-vs-returning-customer-widget-instance', self::NEW_VS_RETURNING_CUSTOMER_TYPE, 60, 1, 2 ),
229            get_dashboard_default_widget_instance( 'default-revenue-by-customer-type-widget-instance', self::REVENUE_BY_CUSTOMER_TYPE_TYPE, 70, 1, 2 ),
230            get_dashboard_default_widget_instance( 'default-total-returns-widget-instance', self::TOTAL_RETURNS_TYPE, 80, 1, 2 ),
231
232            get_dashboard_default_widget_instance( 'default-sales-by-utm-channel-widget-instance', self::SALES_BY_UTM_CHANNEL_TYPE, 90, 1, 2 ),
233            get_dashboard_default_widget_instance( 'default-sales-by-utm-source-widget-instance', self::SALES_BY_UTM_SOURCE_TYPE, 100, 1, 2 ),
234            get_dashboard_default_widget_instance( 'default-sales-by-utm-campaign-widget-instance', self::SALES_BY_UTM_CAMPAIGN_TYPE, 110, 1, 2 ),
235
236            get_dashboard_default_widget_instance( 'default-orders-fulfillment-widget-instance', self::ORDERS_FULFILLMENT_TYPE, 120, 1, 2 ),
237            get_dashboard_default_widget_instance( 'default-sales-by-coupon-widget-instance', self::SALES_BY_COUPON_TYPE, 130, 1, 2 ),
238            get_dashboard_default_widget_instance( 'default-coupon-usage-over-time-widget-instance', self::COUPON_USAGE_OVER_TIME_TYPE, 140, 1, 2 ),
239
240            get_dashboard_default_widget_instance( 'default-payment-status-widget-instance', self::PAYMENT_STATUS_TYPE, 150, 1, 2 ),
241            get_dashboard_default_widget_instance( 'default-sessions-by-device-widget-instance', self::SESSIONS_BY_DEVICE_TYPE, 160, 1, 2 ),
242            get_dashboard_default_widget_instance( 'default-sales-by-device-widget-instance', self::SALES_BY_DEVICE_TYPE, 170, 1, 2 ),
243        );
244    }
245
246    /**
247     * Load the Bookings metric with the dashboard on a store running the Bookings extension.
248     *
249     * Enqueued as a module of its own: the page's boot runs only the init modules its build names,
250     * and a boot dependency is preloaded, not run. The build's own registrant hooks
251     * `wp_default_scripts`, already fired on this page hook, so it runs here.
252     *
253     * @return void
254     */
255    public static function enqueue_store_performance_bookings() {
256        if ( ! self::is_bookings_plugin_active() || ! self::supports_widget_contract() || ! self::is_woocommerce_available() || ! self::current_user_can_view_store_reports() ) {
257            return;
258        }
259
260        self::load_build();
261        if ( ! function_exists( 'jetpack_woocommerce_stats_register_script_modules' ) ) {
262            return;
263        }
264        jetpack_woocommerce_stats_register_script_modules();
265
266        wp_enqueue_script_module( self::STORE_PERFORMANCE_BOOKINGS_MODULE );
267    }
268
269    /**
270     * The manifest minus the categories held back from the tab and the bookings categories
271     * without their extension.
272     *
273     * @param array[] $widget_modules     Widget module records of the build manifest.
274     * @param bool    $bookings_available Whether the WooCommerce Bookings extension is active.
275     * @return array[] The records the package registers.
276     */
277    private static function remove_unavailable_widget_types( $widget_modules, $bookings_available ) {
278        return array_values(
279            array_filter(
280                $widget_modules,
281                static function ( $widget_module ) use ( $bookings_available ) {
282                    $category = $widget_module['category'] ?? '';
283
284                    if ( in_array( $category, self::HELD_BACK_WIDGET_CATEGORIES, true ) ) {
285                        return false;
286                    }
287
288                    return $bookings_available || ! in_array( $category, self::BOOKINGS_WIDGET_CATEGORIES, true );
289                }
290            )
291        );
292    }
293
294    /**
295     * Register the widget types from the package's build manifest.
296     *
297     * Loads the generated build first: after `init` it registers the widget script modules on the
298     * spot, so they reach the page import map the same request.
299     *
300     * Registers nothing without WooCommerce or for a reader who cannot see store reports: the types
301     * would only collect 403s from the proxy.
302     *
303     * @param object $registry The widget type registry being hydrated.
304     * @return void
305     */
306    public static function register_widget_types( $registry ) {
307        if ( ! self::supports_widget_contract() || ! self::is_woocommerce_available() || ! self::current_user_can_view_store_reports() ) {
308            return;
309        }
310
311        self::load_build();
312        if ( ! function_exists( 'jetpack_woocommerce_stats_get_registered_widget_modules' ) ) {
313            return;
314        }
315
316        register_widget_types_from_manifest(
317            self::remove_unavailable_widget_types( jetpack_woocommerce_stats_get_registered_widget_modules(), self::is_bookings_plugin_active() ),
318            array(
319                'textdomain'    => self::TEXTDOMAIN,
320                'i18n_manifest' => add_query_arg( 'ver', self::PACKAGE_VERSION, plugins_url( 'i18n-manifest.json', self::build_dir() . '/build.php' ) ),
321            ),
322            $registry
323        );
324    }
325
326    /**
327     * Whether the WooCommerce tab is available: WooCommerce is active, the reader may see store
328     * reports, and a site that opted in on its own carries the dashboard's store flag.
329     *
330     * @return bool
331     */
332    public static function is_section_available() {
333        return self::is_store_section_enabled() && self::is_woocommerce_available() && self::current_user_can_view_store_reports();
334    }
335
336    /**
337     * Whether WooCommerce is active.
338     *
339     * @return bool
340     */
341    public static function is_woocommerce_available() {
342        $is_available = class_exists( 'WooCommerce' ) || function_exists( 'WC' );
343
344        /**
345         * Filters whether WooCommerce was detected.
346         *
347         * @since $$next-version$$
348         *
349         * @param bool $is_available Whether WooCommerce was detected in the current request.
350         */
351        return (bool) apply_filters( self::WOOCOMMERCE_AVAILABLE_FILTER, $is_available );
352    }
353
354    /**
355     * Whether the current user may read the store reports, the rule the reports proxy enforces.
356     *
357     * @return bool
358     */
359    public static function current_user_can_view_store_reports() {
360        // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers this capability.
361        return current_user_can( 'manage_options' ) || current_user_can( 'view_woocommerce_reports' );
362    }
363
364    /**
365     * The dashboard's soft-launch policy: a site that opted in through the option needs the store
366     * flag; the blog sticker and the enabled filter leave the option off and keep every section.
367     *
368     * @return bool
369     */
370    private static function is_store_section_enabled() {
371        if ( ! get_option( Enablement_Setting::ENABLED_OPTION ) ) {
372            return true;
373        }
374
375        // An older dashboard package may have loaded dashboard-policy.php without the flag.
376        $is_flag_enabled = 'Automattic\\Jetpack\\PremiumAnalytics\\is_dashboard_store_section_enabled';
377
378        return function_exists( $is_flag_enabled ) && $is_flag_enabled();
379    }
380
381    /**
382     * Whether the WooCommerce Bookings extension is active; `is_plugin_active()` only exists in
383     * admin contexts, hence the guard.
384     *
385     * @return bool
386     */
387    private static function is_bookings_plugin_active() {
388        return class_exists( 'WC_Bookings' )
389            || ( function_exists( 'is_plugin_active' ) && \is_plugin_active( 'woocommerce-bookings/woocommerce-bookings.php' ) );
390    }
391
392    /**
393     * Whether the dashboard's widget contract is one the widgets were built against.
394     *
395     * On an older contract the SDK module lacks a name the widgets import, and they fail to load.
396     *
397     * @return bool
398     */
399    private static function supports_widget_contract() {
400        // The dashboard loads the file naming its contract when its widget registry is first read,
401        // which has not happened yet when the dashboard page enqueues its scripts.
402        if (
403            ! defined( 'Automattic\\Jetpack\\PremiumAnalytics\\WIDGET_API_VERSION' )
404            && function_exists( 'Automattic\\Jetpack\\PremiumAnalytics\\ensure_widget_registry_ready' )
405        ) {
406            \Automattic\Jetpack\PremiumAnalytics\ensure_widget_registry_ready();
407        }
408
409        if ( ! defined( 'Automattic\\Jetpack\\PremiumAnalytics\\WIDGET_API_VERSION' ) ) {
410            return false;
411        }
412
413        $version = \Automattic\Jetpack\PremiumAnalytics\WIDGET_API_VERSION;
414
415        return version_compare( $version, self::MIN_WIDGET_API_VERSION, '>=' ) && version_compare( $version, '2', '<' );
416    }
417
418    /**
419     * Load the generated build once. Guarded by symbol, not by path: two copies of the package
420     * can share a request.
421     *
422     * @return void
423     */
424    private static function load_build() {
425        if ( function_exists( 'jetpack_woocommerce_stats_get_registered_widget_modules' ) ) {
426            return;
427        }
428        $loader = self::build_dir() . '/build.php';
429        if ( file_exists( $loader ) ) {
430            require_once $loader;
431        }
432    }
433
434    /**
435     * Directory of the wp-build output.
436     *
437     * @return string
438     */
439    private static function build_dir() {
440        return dirname( __DIR__ ) . '/build';
441    }
442
443    /**
444     * Whether this tab's slug, or the `store` slug an older package still registers, is taken.
445     *
446     * @param object $registry The section registry being hydrated.
447     * @return bool
448     */
449    private static function section_slug_is_taken( $registry ) {
450        foreach ( array( 'woocommerce', 'store' ) as $slug ) {
451            if ( self::dashboard_has_section_slug( $registry, DASHBOARD_NAME, $slug ) ) {
452                return true;
453            }
454        }
455
456        return false;
457    }
458
459    /**
460     * Whether a section with the slug is registered on the dashboard.
461     *
462     * Reads the registry through the slug lookup when the package offers it, and through the full
463     * list otherwise: the package and this one ship on different cadences.
464     *
465     * @param object $registry       The registry being hydrated.
466     * @param string $dashboard_name Dashboard identifier.
467     * @param string $slug           Section slug.
468     * @return bool
469     */
470    private static function dashboard_has_section_slug( $registry, $dashboard_name, $slug ) {
471        if ( method_exists( $registry, 'get_registered_by_slug' ) ) {
472            return null !== $registry->get_registered_by_slug( $dashboard_name, $slug );
473        }
474
475        foreach ( $registry->get_all_registered( $dashboard_name ) as $section ) {
476            if ( $section->slug === $slug ) {
477                return true;
478            }
479        }
480
481        return false;
482    }
483}