Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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.
Expand Down Expand Up @@ -133,14 +120,5 @@ export const queryClient = new QueryClient( {
} );

export const AnalyticsQueryClientProvider = ( { children }: { children: ReactNode } ) => {
return (
<QueryClientProvider client={ queryClient }>
<>{ children }</>
{ ReactQueryDevtools && (
<Suspense fallback={ null }>
<ReactQueryDevtools initialIsOpen={ false } />
</Suspense>
) }
</QueryClientProvider>
);
return <QueryClientProvider client={ queryClient }>{ children }</QueryClientProvider>;
};
4 changes: 4 additions & 0 deletions projects/packages/premium-analytics/src/class-analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
70 changes: 70 additions & 0 deletions projects/packages/premium-analytics/src/widget-availability.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
/**
* Widget type availability: a filterable view over the registry.
*
* The registry (widget-types.php) holds every widget from the build manifest,
* unfiltered. This adds the policy layer: the
* `jetpack_premium_analytics_widget_types` filter, resolved by
* get_available_widget_types(). The REST list and the page import map both read
* through it, so dropping a type there hides it from the client entirely.
*
* Ships one policy: the developer-only React Query Devtools widget is gated to
* non-production. Hook the filter to scope types by capability, flag, site, etc.
*
* @package automattic/jetpack-premium-analytics
*/

namespace Automattic\Jetpack\PremiumAnalytics;

/**
* Filter over the available widget types map (`$name => 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' );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hardcoded list. Maybe we can define a dev category and filter the list by it.


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' );
21 changes: 10 additions & 11 deletions projects/packages/premium-analytics/src/widget-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
/**
* Tests for the widget type availability layer.
*
* @package automattic/jetpack-premium-analytics
*/

namespace Automattic\Jetpack\PremiumAnalytics;

use PHPUnit\Framework\Attributes\CoversFunction;
use WorDBless\BaseTestCase;

require_once __DIR__ . '/../../src/widget-types.php';
require_once __DIR__ . '/../../src/widget-availability.php';

/**
* @covers ::Automattic\Jetpack\PremiumAnalytics\get_available_widget_types
* @covers ::Automattic\Jetpack\PremiumAnalytics\filter_widget_types_by_environment
*/
#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\get_available_widget_types' )]
#[CoversFunction( 'Automattic\Jetpack\PremiumAnalytics\filter_widget_types_by_environment' )]
class Widget_Availability_Test extends BaseTestCase {

/**
* In production, developer-only widget types are dropped while the rest
* pass through untouched.
*/
public function test_filter_removes_dev_only_widget_in_production() {
// WorDBless reports the environment type as 'production' by default.
$this->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.' );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export default {
name: 'jpa/locations',
title: __( 'Locations', 'jetpack-premium-analytics' ),
icon: mapMarker,
presentation: 'full-bleed',
attributes: [
{
id: 'max',
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className={ styles.root }>
<ReactQueryDevtoolsPanel client={ queryClient } style={ { height: '100%' } } />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.root {
height: 100%;
min-height: 480px;
overflow: auto;
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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,
};
Loading