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..78f1b1ac4484 --- /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, gated to non-production environments through a new `jetpack_premium_analytics_widget_types` filter for scoping widget types server-side. 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 daccd953800c..c50101ede430 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -70,6 +70,10 @@ public static function init( $options = array() ) { // Hydrate the widget type registry from the build manifest at init. require_once __DIR__ . '/widget-types.php'; + // Layer the availability filter over the registry (environment gating + // and any host overrides) before the modules below expose the types. + require_once __DIR__ . '/widget-availability.php'; + // 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..6fc61a4b8a18 --- /dev/null +++ b/projects/packages/premium-analytics/src/widget-availability.php @@ -0,0 +1,70 @@ + Widget_Type`). + */ +const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types'; + +/** + * Returns the widget types available for the current request. + * + * Every registered widget type, run through WIDGET_TYPES_FILTER. Use this, not + * get_registered_widget_types(), anywhere widget types reach the client, so the + * same policy covers the REST list and the import map. + * + * @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 `/jetpack/v4/widget-modules` REST list + * and the page import map, so it cannot be rendered or added. + * + * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. + */ + return apply_filters( WIDGET_TYPES_FILTER, get_registered_widget_types() ); +} + +/** + * Hides developer-only widget types in production. + * + * Keyed off wp_get_environment_type(), which defaults to `production`: a site + * opts in by declaring `WP_ENVIRONMENT_TYPE` as `local`, `development`, or + * `staging`. + * + * @param Widget_Type[] $widget_types Map of `$name => Widget_Type`. + * @return Widget_Type[] The map minus developer-only types in production. + */ +function filter_widget_types_by_environment( $widget_types ) { + if ( 'production' !== wp_get_environment_type() ) { + return $widget_types; + } + + // Types that must never reach a production dashboard. + $non_production_only = array( 'jpa/react-query-dev-tool' ); + + foreach ( $non_production_only as $widget_type_name ) { + unset( $widget_types[ $widget_type_name ] ); + } + + return $widget_types; +} + +add_filter( WIDGET_TYPES_FILTER, __NAMESPACE__ . '\\filter_widget_types_by_environment' ); diff --git a/projects/packages/premium-analytics/src/widget-modules.php b/projects/packages/premium-analytics/src/widget-modules.php index 96046e01a8c2..8c591f7e0d43 100644 --- a/projects/packages/premium-analytics/src/widget-modules.php +++ b/projects/packages/premium-analytics/src/widget-modules.php @@ -2,12 +2,10 @@ /** * Dashboard widget modules: REST exposure + import-map wiring. * - * Reads the widget types from Widget_Type_Registry (hydrated from the build - * manifest in widget-types.php) and exposes them to the client through the - * `/jetpack/v4/widget-modules` REST endpoint, plus adds each widget's render - * and metadata modules to the dashboard page's import map so the client can - * dynamically `import()` them on demand. The host feeds the REST records to - * `useWidgetTypes()` in @wordpress/widget-primitives. + * Reads get_available_widget_types() (the registry filtered by + * widget-availability.php) and exposes it two ways: the + * `/jetpack/v4/widget-modules` REST list, and the page import map, where each + * widget's render and metadata modules are registered for dynamic `import()`. * * @package automattic/jetpack-premium-analytics */ @@ -35,14 +33,14 @@ function register_widget_modules_rest_route() { add_action( 'rest_api_init', __NAMESPACE__ . '\\register_widget_modules_rest_route' ); /** - * Build the REST response: one record per registered widget. + * Build the REST response: one record per available widget type. * * @return \WP_REST_Response */ function get_widget_modules_response() { $records = array(); - foreach ( get_registered_widget_types() as $widget_type ) { + foreach ( get_available_widget_types() as $widget_type ) { $records[] = array( 'name' => $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/tests/php/Widget_Availability_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php new file mode 100644 index 000000000000..bdc2a9bc6151 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Widget_Availability_Test.php @@ -0,0 +1,64 @@ +assertSame( 'production', wp_get_environment_type() ); + + $widget_types = array( + 'jpa/react-query-dev-tool' => new Widget_Type( 'jpa/react-query-dev-tool' ), + 'jpa/hello-world' => new Widget_Type( 'jpa/hello-world' ), + ); + + $filtered = filter_widget_types_by_environment( $widget_types ); + + $this->assertArrayNotHasKey( 'jpa/react-query-dev-tool', $filtered, 'Developer-only widgets must be hidden in production.' ); + $this->assertArrayHasKey( 'jpa/hello-world', $filtered, 'Regular widgets remain available.' ); + } + + /** + * Verifies get_available_widget_types() runs the registry through the + * WIDGET_TYPES_FILTER so any callback can scope the visible set. + */ + 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..b1286a25a698 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/react-query-dev-tool/widget.ts @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { bug } from '@wordpress/icons'; + +/** + * Widget type definition. + * + * Developer tool, gated to non-production server-side by the + * `jetpack_premium_analytics_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, +};