diff --git a/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget b/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget new file mode 100644 index 000000000000..3765e3e61689 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-react-query-devtools-widget @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Dashboard: add a React Query Devtools widget (non-production only), and expose two server-side widget-type filters (at registration and on read) so consumers can scope which widget types reach the dashboard. diff --git a/projects/packages/premium-analytics/packages/data/package.json b/projects/packages/premium-analytics/packages/data/package.json index 46765a1e3e32..79b6e32b2af5 100644 --- a/projects/packages/premium-analytics/packages/data/package.json +++ b/projects/packages/premium-analytics/packages/data/package.json @@ -18,8 +18,5 @@ "@wordpress/i18n": "^6.9.0", "@wordpress/url": "4.48.1", "date-fns": "4.1.0" - }, - "devDependencies": { - "@tanstack/react-query-devtools": "5.90.2" } } diff --git a/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx b/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx index dfb504ca2e26..4121d4466454 100644 --- a/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx +++ b/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; -import { ReactNode, lazy, Suspense } from 'react'; +import { ReactNode } from 'react'; /** * Internal dependencies */ @@ -11,19 +11,6 @@ import { globalErrorManager } from './global-error-manager'; const DEFAULT_STALE_TIME = 5 * 60 * 1000; const DEFAULT_GC_TIME = 10 * 60 * 1000; -// Upstream gates devtools behind an admin-toolkit experiment flag; that system -// isn't available here, so we show them in dev builds only. Gate the `lazy()` -// creation on NODE_ENV (not just the render) so the dynamic import sits in a -// dead branch that production builds tree-shake out — no orphaned chunk. -const ReactQueryDevtools = - process.env.NODE_ENV !== 'production' - ? lazy( () => - import( '@tanstack/react-query-devtools' ).then( d => ( { - default: d.ReactQueryDevtools, - } ) ) - ) - : null; - /** * Extract HTTP status code from various error formats. * WordPress REST API errors may have different shapes. @@ -133,14 +120,5 @@ export const queryClient = new QueryClient( { } ); export const AnalyticsQueryClientProvider = ( { children }: { children: ReactNode } ) => { - return ( - - <>{ children } - { ReactQueryDevtools && ( - - - - ) } - - ); + return { children }; }; diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index 9973bf8436a3..33d91f18fd87 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -67,9 +67,17 @@ public static function init( $options = array() ) { Api_Proxy_Controller::register(); Notices_Controller::register(); - // Hydrate the widget type registry from the build manifest at init. + // Load the widget type registry: hydration routine, registry-time and + // runtime filters, and the registry accessors. require_once __DIR__ . '/widget-types.php'; + // Apply Premium Analytics' availability policy: hooks the registry-time + // filter to keep developer-only types out of production. + require_once __DIR__ . '/widget-availability.php'; + + // Hydrate the registry with the availability filter in place. + bootstrap_widget_types(); + // Expose dashboard widget modules over REST and wire them into the // page import map for dynamic import() on the client. require_once __DIR__ . '/widget-modules.php'; diff --git a/projects/packages/premium-analytics/src/widget-availability.php b/projects/packages/premium-analytics/src/widget-availability.php new file mode 100644 index 000000000000..faa7dd68fda3 --- /dev/null +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -0,0 +1,61 @@ + $widget_type->name, 'render_module' => $widget_type->render_module, @@ -55,20 +53,21 @@ function get_widget_modules_response() { } /** - * Add registered widget modules to the dashboard page import map as dynamic - * dependencies, so the client can `import()` them on demand. + * Add available widget modules to the page import map as dynamic dependencies, + * so the client can `import()` them on demand. * * @param array $boot_dependencies Boot dependencies for the page. * @return array Updated boot dependencies. */ function add_widget_modules_to_boot_deps( $boot_dependencies ) { - foreach ( get_registered_widget_types() as $widget_type ) { + foreach ( get_available_widget_types() as $widget_type ) { if ( ! empty( $widget_type->render_module ) ) { $boot_dependencies[] = array( 'import' => 'dynamic', 'id' => $widget_type->render_module, ); } + if ( ! empty( $widget_type->widget_module ) ) { $boot_dependencies[] = array( 'import' => 'dynamic', diff --git a/projects/packages/premium-analytics/src/widget-types.php b/projects/packages/premium-analytics/src/widget-types.php index e3f71ba7f599..3acc82219790 100644 --- a/projects/packages/premium-analytics/src/widget-types.php +++ b/projects/packages/premium-analytics/src/widget-types.php @@ -1,14 +1,19 @@ is_registered( $widget['name'] ) ) { continue; @@ -55,20 +79,49 @@ function register_widget_types() { } } -if ( did_action( 'init' ) ) { - register_widget_types(); -} else { - add_action( 'init', __NAMESPACE__ . '\\register_widget_types' ); +/** + * Hydrates the registry now if init has run, otherwise on init. + * + * Call after the availability filters are hooked, so the registry-time + * filter applies during hydration. + * + * @return void + */ +function bootstrap_widget_types() { + if ( did_action( 'init' ) ) { + register_widget_types(); + } else { + add_action( 'init', __NAMESPACE__ . '\\register_widget_types' ); + } } /** - * Returns all widget types registered in the registry. + * Returns the raw registry. For the client-facing set use + * get_available_widget_types(). * - * Convenience accessor around `Widget_Type_Registry::get_all_registered()` for - * callers that prefer a function-based API. - * - * @return Widget_Type[] Associative array of `$name => $widget_type` pairs. + * @return Widget_Type[] Map of `$name => $widget_type`. */ function get_registered_widget_types() { return Widget_Type_Registry::get_instance()->get_all_registered(); } + +/** + * Returns the registered widget types scoped through WIDGET_TYPES_FILTER. + * + * Use this, not get_registered_widget_types(), wherever widget types reach the + * client, so the REST list and import map share one policy. + * + * @return Widget_Type[] Map of `$name => Widget_Type`. + */ +function get_available_widget_types() { + /** + * Filters the widget types available to the dashboard this request. + * + * Removing an entry drops it from the REST list and the import map. The type + * stays registered, so use this (not the registry-time filter) when a + * consumer must still see it, e.g. to show it locked. + * + * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. + */ + return apply_filters( WIDGET_TYPES_FILTER, get_registered_widget_types() ); +} diff --git a/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php new file mode 100644 index 000000000000..199920f44556 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -0,0 +1,93 @@ + 'jpa/react-query-dev-tool' ), + array( 'name' => 'jpa/hello-world' ), + ); + } + + /** + * In production, developer-only candidates are dropped; the rest pass through. + */ + public function test_dev_only_widget_removed_in_production() { + $names = array_column( remove_dev_only_widget_types( $this->widget_candidates(), 'production' ), 'name' ); + + $this->assertNotContains( 'jpa/react-query-dev-tool', $names, 'Developer-only widgets must be hidden in production.' ); + $this->assertContains( 'jpa/hello-world', $names, 'Regular widgets remain available.' ); + } + + /** + * Outside production, candidates pass through (covers the non-production branch). + */ + public function test_dev_only_widget_kept_outside_production() { + foreach ( array( 'local', 'development', 'staging' ) as $environment ) { + $names = array_column( remove_dev_only_widget_types( $this->widget_candidates(), $environment ), 'name' ); + + $this->assertContains( 'jpa/react-query-dev-tool', $names, "Developer-only widgets must remain available in the {$environment} environment." ); + $this->assertContains( 'jpa/hello-world', $names, 'Regular widgets remain available.' ); + } + } + + /** + * The registry-time callback reads the env (production by default) and drops + * the developer-only candidate. + */ + public function test_registry_filter_callback_drops_dev_widget_by_default() { + $this->assertSame( 'production', wp_get_environment_type() ); + + $names = array_column( filter_registrable_widget_types_by_environment( $this->widget_candidates() ), 'name' ); + + $this->assertNotContains( 'jpa/react-query-dev-tool', $names, 'The registry-time callback must drop the developer widget in production.' ); + $this->assertContains( 'jpa/hello-world', $names, 'Regular widgets remain available.' ); + } + + /** + * Reading the available set runs the registry through WIDGET_TYPES_FILTER. + */ + public function test_get_available_widget_types_applies_filter() { + $registry = Widget_Type_Registry::get_instance(); + $registry->register( 'test/sentinel' ); + + $callback = static function ( $widget_types ) { + unset( $widget_types['test/sentinel'] ); + return $widget_types; + }; + add_filter( WIDGET_TYPES_FILTER, $callback ); + + $available = get_available_widget_types(); + + remove_filter( WIDGET_TYPES_FILTER, $callback ); + $registry->unregister( 'test/sentinel' ); + + $this->assertArrayNotHasKey( 'test/sentinel', $available, 'A filter callback can remove a widget type from the available set.' ); + } +} diff --git a/projects/packages/premium-analytics/widgets/locations/widget.ts b/projects/packages/premium-analytics/widgets/locations/widget.ts index afa65d07ba8a..49100a79c20c 100644 --- a/projects/packages/premium-analytics/widgets/locations/widget.ts +++ b/projects/packages/premium-analytics/widgets/locations/widget.ts @@ -23,7 +23,6 @@ export default { name: 'jpa/locations', title: __( 'Locations', 'jetpack-premium-analytics' ), icon: mapMarker, - presentation: 'full-bleed', attributes: [ { id: 'max', diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json b/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json new file mode 100644 index 000000000000..a4069b1b65ac --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/package.json @@ -0,0 +1,13 @@ +{ + "name": "@automattic/jetpack-premium-analytics-widget-react-query-dev-tool", + "version": "0.1.0-alpha", + "private": true, + "type": "module", + "dependencies": { + "@jetpack-premium-analytics/data": "link:../../packages/data", + "@tanstack/react-query-devtools": "5.90.2", + "@wordpress/i18n": "^6.9.0", + "@wordpress/icons": "^13.0.0", + "react": "18.3.1" + } +} diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx new file mode 100644 index 000000000000..35d1c5fca3cb --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/render.tsx @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import { queryClient } from '@jetpack-premium-analytics/data'; +import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; +/** + * Internal dependencies + */ +import styles from './style.module.css'; + +/** + * React Query Devtools as a dashboard widget. + * + * Bound to the shared `queryClient` via the explicit `client` prop rather than + * the React Query context. The panel renders inside this widget's own lazily + * loaded module, so a context lookup is not guaranteed to resolve to the + * dashboard's provider; passing the singleton directly always targets the real + * cache. + * + * Server-gated: widget-availability.php drops `jpa/react-query-dev-tool` in + * production, so this module is never requested there. + * + * @return {React.ReactNode} The rendered devtools panel. + */ +export default function ReactQueryDevTool(): React.ReactNode { + return ( +
+ +
+ ); +} diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/style.module.css b/projects/packages/premium-analytics/widgets/react-query-dev-tool/style.module.css new file mode 100644 index 000000000000..5bd9e47257a5 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/style.module.css @@ -0,0 +1,5 @@ +.root { + height: 100%; + min-height: 480px; + overflow: auto; +} diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.json b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.json new file mode 100644 index 000000000000..b5699f2d5d30 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.json @@ -0,0 +1,7 @@ +{ + "name": "jpa/react-query-dev-tool", + "title": "React Query Devtools", + "description": "Inspect the dashboard's React Query cache. Available outside production only.", + "category": "developer", + "presentation": "full-bleed" +} diff --git a/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts new file mode 100644 index 000000000000..bdc86cfe9220 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { bug } from '@wordpress/icons'; + +/** + * Widget type definition. + * + * Developer tool, dropped from production at registration time by the + * `jetpack_premium_analytics_registrable_widget_types` filter + * (widget-availability.php). This metadata only describes the type for the + * dashboard's widget picker. + */ +export default { + name: 'jpa/react-query-dev-tool', + title: __( 'React Query Devtools', 'jetpack-premium-analytics' ), + icon: bug, +};