Skip to content

Commit 3539af2

Browse files
authored
Merge 40475fa into 5891e27
2 parents 5891e27 + 40475fa commit 3539af2

20 files changed

Lines changed: 312 additions & 45 deletions

‎docs/explanations/architecture/assets/dashboard-widgets-pipeline.svg‎

Lines changed: 1 addition & 1 deletion
Loading

‎docs/explanations/architecture/dashboard-widgets.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ A widget is a directory under `widgets/`, discovered by convention; there is no
2222

2323
```
2424
widgets/hello-world/
25-
├── widget.json static metadata (name, title, description, help, icon, actions, attributes, keywords, category, presentation, textdomain)
26-
├── widget.ts optional metadata module: default-exports example, and attribute props that need code
25+
├── widget.json static metadata (name, title, description, help, icon, actions, attributes, example, keywords, category, presentation, textdomain)
26+
├── widget.ts optional metadata module: default-exports the attribute props and example values that need code
2727
├── render.tsx render module: default-exports the React component
2828
├── style.module.css optional, injected at runtime by the build
2929
└── report.csv optional static asset; action `href`s resolve it only where `widgets/` ships
@@ -39,7 +39,9 @@ Unlike the other translatable strings, `help` is an object: `content` plus optio
3939

4040
`attributes`: the schema hosts render settings from, one entry per attribute with the JSON-expressible subset of a DataViews `Field` (`id`, `type`, `label`, `elements`, `isValid` without `custom`, ...) plus an optional `relevance` hint. `type` may name a registered field type. Labels, headers, descriptions, placeholders, and option labels and descriptions are translated server-side.
4141

42-
`widget.ts` is the live half of the metadata: values that only exist in JavaScript, such as `example` data, or an attribute's `Edit` component or custom validator. It is optional: a widget declared entirely by its manifest ships none, and resolves from its record alone.
42+
`example`: the attributes hosts preview the widget with, and apply to a new instance created without any, as `{ "attributes": { … } }`. JSON values only, and not translated: example values are data.
43+
44+
`widget.ts` is the live half of the metadata: values that only exist in JavaScript, such as an attribute's `Edit` component or custom validator, or `example` values that need code. It is optional: a widget declared entirely by its manifest ships none, and resolves from its record alone.
4345

4446
Its render component receives the widget's `attributes` and, optionally, `setAttributes`:
4547

@@ -58,13 +60,13 @@ export default function HelloWorld( { attributes } ) { ... }
5860

5961
## The server registry
6062

61-
`WP_Widget_Type_Registry` (`lib/experimental/dashboard-widgets/`) is a singleton, hydrated at `init` from the manifest. Each entry becomes a `WP_Widget_Type` with `name`, `render_module`, `widget_module`, `presentation`, `category`, `icon`, and the translatable `title`, `description`, `help`, `actions`, `attributes`, and `keywords` (localized at registration time using the widget's `textdomain`).
63+
`WP_Widget_Type_Registry` (`lib/experimental/dashboard-widgets/`) is a singleton, hydrated at `init` from the manifest. Each entry becomes a `WP_Widget_Type` with `name`, `render_module`, `widget_module`, `presentation`, `category`, `icon`, `example`, and the translatable `title`, `description`, `help`, `actions`, `attributes`, and `keywords` (localized at registration time using the widget's `textdomain`).
6264

6365
The hydration is a deterministic copy, with no filters in between. The `widgets/` folder is the single source of widget authorship in this codebase.
6466

6567
The registry is the server's authoritative list of widget types for the site. Two consumers read it:
6668

67-
- The REST controller (`WP_REST_Widget_Modules_Controller`) exposes it at `/wp/v2/widget-modules`, returning `{ name, render_module, widget_module, presentation, category, title, description, help, icon, actions, attributes, keywords }` per record.
69+
- The REST controller (`WP_REST_Widget_Modules_Controller`) exposes it at `/wp/v2/widget-modules`, returning `{ name, render_module, widget_module, presentation, category, title, description, help, icon, actions, attributes, example, keywords }` per record.
6870
- The dashboard page hooks its `dashboard-wp-admin_boot_dependencies` filter, a per-page instance of the generic `{page-slug}-wp-admin_boot_dependencies`, and adds every registered module to its import map as a `dynamic` dependency. A dynamic dependency is reachable by `import()` but never executed eagerly.
6971

7072
Registration only makes the modules known to WordPress; loading them is a separate, per-host decision. Dynamic `import()` against the import map is how the dashboard loads widgets today. A host can load them another way: enqueue a module eagerly (`wp_enqueue_script_module()`), declare it as a `static` dependency of its own module, or, outside WordPress, skip the import map and resolve modules through its own `ResolveWidgetModule`.
@@ -75,7 +77,7 @@ The registry exists as a class, rather than having REST read the manifest direct
7577

7678
Everything after the REST record is the job of [`@wordpress/widget-primitives`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/widget-primitives), the contract both widget authors and hosts share. Its full surface (the contract types, the discovery hook, and the render component) is covered in the _Widget Primitives / Introduction_ story. In the pipeline it does two things.
7779

78-
`useWidgetTypes( records )` takes the host-supplied records, imports each record's `widget_module` for the live metadata, and merges it with the record into `WidgetType[]`. A record without a metadata module resolves from its own fields, as long as it carries a `render_module`. The record's `presentation`, `category`, `title`, `description`, `help`, `actions`, `attributes`, and `keywords`, all sourced from `widget.json` (with `title`, `description`, `help`, `actions`, `attributes`, and `keywords` localized server-side), win over the module's value. `attributes` merges per entry by `id`: the record wins a shared key, a module entry adds what JSON cannot express, `isValid` merges rule by rule, and module-only entries follow. The record's `icon` is a registered icon name: the hook resolves it through the application-registered resolver (`registerIconResolver`), and the resolved element wins over a module's element. The hook reaches for no store or endpoint; a host such as the dashboard reads its own `widgetModule` core-data entity (backed by `/wp/v2/widget-modules`) and passes the records in.
80+
`useWidgetTypes( records )` takes the host-supplied records, imports each record's `widget_module` for the live metadata, and merges it with the record into `WidgetType[]`. A record without a metadata module resolves from its own fields, as long as it carries a `render_module`. The record's `presentation`, `category`, `title`, `description`, `help`, `actions`, `attributes`, and `keywords`, all sourced from `widget.json` (with `title`, `description`, `help`, `actions`, `attributes`, and `keywords` localized server-side), win over the module's value. `attributes` merges per entry by `id`: the record wins a shared key, a module entry adds what JSON cannot express, `isValid` merges rule by rule, and module-only entries follow. `example` is the exception: the module's wins, and the record's stands in only when the module declares none, so values that need code stay in the module. The record's `icon` is a registered icon name: the hook resolves it through the application-registered resolver (`registerIconResolver`), and the resolved element wins over a module's element. The hook reaches for no store or endpoint; a host such as the dashboard reads its own `widgetModule` core-data entity (backed by `/wp/v2/widget-modules`) and passes the records in.
7981

8082
An attribute may also reference a field type by name (`"type": "location"`). The application registers those definitions up front through `registerFieldType()` (the dashboard route registers its own on boot), and `useWidgetTypes` resolves every named reference through that registry while building each `WidgetType`: the registered definition supplies the field's behavior on top of its DataViews `baseType`, and hosts receive plain DataViews fields. The widget declaration stays serializable; resolution happens once, at this boundary.
8183

‎lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ public function prepare_item_for_response( $item, $request ) {
223223
$data['attributes'] = $widget_type->attributes;
224224
}
225225

226+
if ( rest_is_field_included( 'example', $fields ) ) {
227+
$data['example'] = $widget_type->example;
228+
}
229+
226230
if ( rest_is_field_included( 'keywords', $fields ) ) {
227231
$data['keywords'] = $widget_type->keywords;
228232
}
@@ -394,6 +398,20 @@ public function get_item_schema() {
394398
'readonly' => true,
395399
),
396400

401+
'example' => array(
402+
'description' => __( 'Structured example: the attributes hosts preview the widget type with, and apply to a new instance created without any.', 'gutenberg' ),
403+
'type' => array( 'object', 'null' ),
404+
'properties' => array(
405+
'attributes' => array(
406+
'description' => __( 'Attribute values by attribute name.', 'gutenberg' ),
407+
'type' => 'object',
408+
'additionalProperties' => true,
409+
),
410+
),
411+
'context' => array( 'view', 'edit', 'embed' ),
412+
'readonly' => true,
413+
),
414+
397415
'keywords' => array(
398416
'description' => __( 'Alternative terms used to match the widget type when searching, e.g. "calendar" for an events widget. Translatable.', 'gutenberg' ),
399417
'type' => array( 'array', 'null' ),

‎lib/experimental/dashboard-widgets/class-wp-widget-type.php‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
* inside `WP_Widget_Type_Registry` once registered, and consumed by host
1515
* code that needs to enumerate or look up widget types.
1616
*
17-
* The shape is intentionally minimal: identity (`name`) plus the
18-
* script-module handles the build pipeline produced for the widget.
17+
* The shape mirrors `widget.json`: identity (`name`), the script-module
18+
* handles the build pipeline produced for the widget, and the declarative
19+
* metadata the manifest carries, translated and sanitized at registration.
1920
* Placement and host concerns (which page or sidebar uses the widget)
2021
* live with the consumer, not on the type definition.
2122
*/
@@ -142,6 +143,17 @@ class WP_Widget_Type {
142143
*/
143144
public $attributes = null;
144145

146+
/**
147+
* Structured example: `attributes` maps attribute names to the JSON
148+
* values hosts preview the widget with, and apply to a new instance
149+
* created without attributes of its own. Not translated.
150+
*
151+
* Null when the widget did not declare the field.
152+
*
153+
* @var array|null
154+
*/
155+
public $example = null;
156+
145157
/**
146158
* Alternative terms used to match the widget type when searching,
147159
* e.g. "calendar" for an events widget. Translated at registration

‎lib/experimental/dashboard-widgets/widget-types.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,59 @@ function gutenberg_sanitize_widget_attributes( $attributes ) {
374374
return $sanitized ? $sanitized : null;
375375
}
376376

377+
/**
378+
* Whether a value is JSON data: null, a boolean, a finite number, a string,
379+
* or an array of such values.
380+
*
381+
* @param mixed $value Value to check.
382+
* @return bool True when the value encodes as JSON data.
383+
*/
384+
function gutenberg_is_widget_json_value( $value ) {
385+
if ( is_array( $value ) ) {
386+
foreach ( $value as $item ) {
387+
if ( ! gutenberg_is_widget_json_value( $item ) ) {
388+
return false;
389+
}
390+
}
391+
return true;
392+
}
393+
394+
if ( is_float( $value ) ) {
395+
return is_finite( $value );
396+
}
397+
398+
return null === $value || is_bool( $value ) || is_int( $value ) || is_string( $value );
399+
}
400+
401+
/**
402+
* Sanitizes a widget example to its `attributes`: a map of attribute names
403+
* to JSON values (null, booleans, finite numbers, strings, and arrays of
404+
* those). Any other key drops. An attribute without a string name, or whose
405+
* value is not JSON data, drops, never the example; an example left without
406+
* attributes becomes null.
407+
*
408+
* This is the registration gate for manifest-sourced widget types, the same
409+
* boundary `gutenberg_sanitize_widget_attributes()` guards.
410+
*
411+
* @param array|null $example Example from the build manifest.
412+
* @return array|null Sanitized example, or null.
413+
*/
414+
function gutenberg_sanitize_widget_example( $example ) {
415+
if ( ! is_array( $example ) || ! isset( $example['attributes'] ) || ! is_array( $example['attributes'] ) ) {
416+
return null;
417+
}
418+
419+
$attributes = array();
420+
foreach ( $example['attributes'] as $name => $value ) {
421+
if ( ! is_string( $name ) || '' === $name || ! gutenberg_is_widget_json_value( $value ) ) {
422+
continue;
423+
}
424+
$attributes[ $name ] = $value;
425+
}
426+
427+
return $attributes ? array( 'attributes' => $attributes ) : null;
428+
}
429+
377430
/**
378431
* Constrains a widget icon reference to a registered icon name
379432
* (`collection/icon-name`). Anything else drops silently, so authoring
@@ -433,6 +486,7 @@ function gutenberg_register_widget_types() {
433486
$widget['dir_name'] ?? ''
434487
),
435488
'attributes' => gutenberg_sanitize_widget_attributes( $widget['attributes'] ?? null ),
489+
'example' => gutenberg_sanitize_widget_example( $widget['example'] ?? null ),
436490
'keywords' => $widget['keywords'] ?? null,
437491
)
438492
);

‎packages/widget-primitives/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### New Features
6+
7+
- `WidgetModuleRecord.example`: a record carries the `example` `widget.json` declares, in the shape of the runtime field; `useWidgetTypes` reads it for a record without a metadata module, and when the module declares no `example` of its own ([#85240](https://github.com/WordPress/gutenberg/pull/85240)).
8+
59
## 0.10.0 (2026-10-20)
610

711
### Breaking Changes

‎packages/widget-primitives/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ This package ships no stylesheets; there is nothing to enqueue or import.
2929
The contract types, `<WidgetRender>`, and `useWidgetTypes()` work in any
3030
React application. The host fetches the widget-module records however it
3131
wants and passes them in; `useWidgetTypes( records )` imports each record's
32-
metadata module and returns the resolved `WidgetType[]`.
32+
metadata module, when it declares one, and returns the resolved `WidgetType[]`.
3333

3434
On a WordPress site the records come from the `/wp/v2/widget-modules` REST
3535
endpoint, exposed while the `gutenberg-dashboard-widgets` experiment is
@@ -51,7 +51,7 @@ Error handling and chrome stay with the host, which wraps the lazy render in a `
5151

5252
### `useWidgetTypes( records )`
5353

54-
It takes host-supplied records (`WidgetModuleRecord[]`, or `null` while loading) and imports each one's metadata module. It returns `[ widgetTypes, isResolvingWidgetTypes ]`; the flag stays `true` until they resolve.
54+
It takes host-supplied records (`WidgetModuleRecord[]`, or `null` while loading) and imports each one's metadata module when it declares one. A record without one resolves from its own fields. It returns `[ widgetTypes, isResolvingWidgetTypes ]`; the flag stays `true` until they resolve.
5555

5656
### `useWidgetActions( actions )`
5757

‎packages/widget-primitives/src/hooks/test/fixtures/widget-module.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default {
88
apiVersion: 1,
99
title: 'Store',
1010
icon: moduleIcon,
11+
example: { attributes: { location: 'Module', label: 'Store' } },
1112
attributes: [
1213
{
1314
id: 'location',

‎packages/widget-primitives/src/hooks/test/use-widget-types.jsdom.test.tsx‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,27 @@ const actionIconRecords: WidgetModuleRecord[] = [
105105
},
106106
];
107107

108+
const exampleRecords: WidgetModuleRecord[] = [
109+
{
110+
...moduleLessRecords[ 0 ],
111+
example: { attributes: { message: 'Hello from the record' } },
112+
},
113+
];
114+
115+
const moduleWithoutExampleRecords: WidgetModuleRecord[] = [
116+
{
117+
...stringIconRecords[ 0 ],
118+
example: { attributes: { message: 'Hello from the record' } },
119+
},
120+
];
121+
122+
const moduleExampleRecords: WidgetModuleRecord[] = [
123+
{
124+
...records[ 0 ],
125+
example: { attributes: { location: 'Record' } },
126+
},
127+
];
128+
108129
describe( 'useWidgetTypes', () => {
109130
afterEach( () => {
110131
unregisterFieldType( 'test/location' );
@@ -231,6 +252,40 @@ describe( 'useWidgetTypes', () => {
231252
expect( attributes[ 2 ] ).toMatchObject( { label: 'Label' } );
232253
} );
233254

255+
it( 'reads the record example without a metadata module', async () => {
256+
const { result } = renderHook( () => useWidgetTypes( exampleRecords ) );
257+
258+
await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) );
259+
260+
expect( result.current[ 0 ][ 0 ].example ).toEqual( {
261+
attributes: { message: 'Hello from the record' },
262+
} );
263+
} );
264+
265+
it( 'falls back to the record example when the module declares none', async () => {
266+
const { result } = renderHook( () =>
267+
useWidgetTypes( moduleWithoutExampleRecords )
268+
);
269+
270+
await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) );
271+
272+
expect( result.current[ 0 ][ 0 ].example ).toEqual( {
273+
attributes: { message: 'Hello from the record' },
274+
} );
275+
} );
276+
277+
it( 'keeps the module example over the record', async () => {
278+
const { result } = renderHook( () =>
279+
useWidgetTypes( moduleExampleRecords )
280+
);
281+
282+
await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) );
283+
284+
expect( result.current[ 0 ][ 0 ].example ).toEqual( {
285+
attributes: { location: 'Module', label: 'Store' },
286+
} );
287+
} );
288+
234289
it( 'drops a record with neither metadata nor render module', async () => {
235290
const { result } = renderHook( () => useWidgetTypes( emptyRecords ) );
236291

‎packages/widget-primitives/src/hooks/use-widget-types.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ function recordOverlay( record: WidgetModuleRecord ) {
135135
* DataViews fields. Icon references resolve through the registered icon
136136
* resolver, off the loading flag: widget types emit as soon as their modules
137137
* land, and each resolved icon patches in afterwards. Action icon references
138-
* resolve the same way.
138+
* resolve the same way. A record's `example` stands in when the module
139+
* declares none.
139140
* Pass `null`/`undefined` while records are still loading.
140141
*
141142
* @param records Host-supplied records, or `null`/`undefined` while loading.
@@ -186,6 +187,9 @@ export function useWidgetTypes(
186187
),
187188
}
188189
: {} ),
190+
...( record.example
191+
? { example: record.example }
192+
: {} ),
189193
...( record.icon ? { icon: pendingIcon } : {} ),
190194
...( record.actions
191195
? {
@@ -226,13 +230,15 @@ export function useWidgetTypes(
226230
record.attributes,
227231
metadata.attributes
228232
);
233+
const example = metadata.example ?? record.example;
229234

230235
return {
231236
apiVersion: DEFAULT_API_VERSION,
232237
...metadata,
233238
...( attributes
234239
? { attributes: resolveFields( attributes ) }
235240
: {} ),
241+
...( example ? { example } : {} ),
236242
icon,
237243
/*
238244
* `title` is required:

0 commit comments

Comments
 (0)