diff --git a/docs/explanations/architecture/dashboard-widgets.md b/docs/explanations/architecture/dashboard-widgets.md index 59e58d110fc03c..480479b49fdaed 100644 --- a/docs/explanations/architecture/dashboard-widgets.md +++ b/docs/explanations/architecture/dashboard-widgets.md @@ -22,7 +22,7 @@ A widget is a directory under `widgets/`, discovered by convention; there is no ``` widgets/hello-world/ -├── widget.json static metadata (name, title, description, help, keywords, category, presentation, textdomain) +├── widget.json static metadata (name, title, description, help, actions, keywords, category, presentation, textdomain) ├── widget.ts metadata module: default-exports icon, attributes, example ├── render.tsx render module: default-exports the React component └── style.module.css optional, injected at runtime by the build @@ -32,6 +32,8 @@ The split between `widget.json` and `widget.ts` is deliberate. `widget.json` is Unlike the other translatable strings, `help` is an object: `content` plus optional `links`, meant for compact surfaces such as tooltips. +`actions` is a list of the declarative actions a widget exposes: each carries `id`, `label`, `href`, and optional `download` / `openInNewTab`, with the label translated. A host renders them as links and decides where; the dashboard surfaces them in a "More" menu. Because navigation and download are the browser's, the widget declares an intent and a target without knowing its surface. + `widget.ts` is the live half of the metadata: values that only exist in JavaScript, such as the icon element or the `attributes` field schema (including optional `relevance` hints) that hosts feed into `DataForm`. Its render component receives the widget's `attributes` and, optionally, `setAttributes`: @@ -51,13 +53,13 @@ export default function HelloWorld( { attributes } ) { ... } ## The server registry -`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`, and the translatable `title`, `description`, `help`, and `keywords` (localized at registration time using the widget's `textdomain`). +`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`, and the translatable `title`, `description`, `help`, `actions`, and `keywords` (localized at registration time using the widget's `textdomain`). The hydration is a deterministic copy, with no filters in between. The `widgets/` folder is the single source of widget authorship in this codebase. The registry is the server's authoritative list of widget types for the site. Two consumers read it: -- 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, keywords }` per record. +- 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, actions, keywords }` per record. - 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. 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`. @@ -68,7 +70,7 @@ The registry exists as a class, rather than having REST read the manifest direct 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. -`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[]`. The record's `presentation`, `category`, `title`, `description`, `help`, and `keywords`, all sourced from `widget.json` (with `title`, `description`, `help`, and `keywords` localized server-side), win over the module's value. 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. +`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[]`. The record's `presentation`, `category`, `title`, `description`, `help`, `actions`, and `keywords`, all sourced from `widget.json` (with `title`, `description`, `help`, `actions`, and `keywords` localized server-side), win over the module's value. 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. A module's `attributes` may also reference field types 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. diff --git a/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php b/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php index 053a22b121d2a0..46deb1ac6d8ccd 100644 --- a/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php +++ b/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php @@ -211,6 +211,10 @@ public function prepare_item_for_response( $item, $request ) { $data['help'] = $widget_type->help; } + if ( rest_is_field_included( 'actions', $fields ) ) { + $data['actions'] = $widget_type->actions; + } + if ( rest_is_field_included( 'keywords', $fields ) ) { $data['keywords'] = $widget_type->keywords; } @@ -311,6 +315,23 @@ public function get_item_schema() { 'readonly' => true, ), + 'actions' => array( + 'description' => __( 'Declarative actions the widget type exposes. Labels are translatable.', 'gutenberg' ), + 'type' => array( 'array', 'null' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'string' ), + 'label' => array( 'type' => 'string' ), + 'href' => array( 'type' => 'string' ), + 'download' => array( 'type' => array( 'string', 'boolean' ) ), + 'openInNewTab' => array( 'type' => 'boolean' ), + ), + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'keywords' => array( 'description' => __( 'Alternative terms used to match the widget type when searching, e.g. "calendar" for an events widget. Translatable.', 'gutenberg' ), 'type' => array( 'array', 'null' ), diff --git a/lib/experimental/dashboard-widgets/class-wp-widget-type.php b/lib/experimental/dashboard-widgets/class-wp-widget-type.php index 046abf6fa4b4f7..d46ad31a740e3f 100644 --- a/lib/experimental/dashboard-widgets/class-wp-widget-type.php +++ b/lib/experimental/dashboard-widgets/class-wp-widget-type.php @@ -107,6 +107,17 @@ class WP_Widget_Type { */ public $help = null; + /** + * Declarative actions the widget exposes. Each entry carries `id`, + * `label`, `href`, and optional `download`/`openInNewTab`. Labels are + * translated at registration time using the widget's text domain. + * + * Null when the widget did not declare the field. + * + * @var array|null + */ + public $actions = null; + /** * Alternative terms used to match the widget type when searching, * e.g. "calendar" for an events widget. Translated at registration diff --git a/lib/experimental/dashboard-widgets/widget-i18n.json b/lib/experimental/dashboard-widgets/widget-i18n.json index 859fa3ef5dc81d..a5ef9b522f6a5d 100644 --- a/lib/experimental/dashboard-widgets/widget-i18n.json +++ b/lib/experimental/dashboard-widgets/widget-i18n.json @@ -5,5 +5,6 @@ "content": "widget help content", "links": [ { "label": "widget help link label" } ] }, + "actions": [ { "label": "widget action label" } ], "keywords": [ "widget keyword" ] } diff --git a/lib/experimental/dashboard-widgets/widget-types.php b/lib/experimental/dashboard-widgets/widget-types.php index 31e6673d9bc096..223091d83b69bc 100644 --- a/lib/experimental/dashboard-widgets/widget-types.php +++ b/lib/experimental/dashboard-widgets/widget-types.php @@ -40,8 +40,8 @@ function gutenberg_get_widget_metadata_i18n_schema() { /** * Translates a widget's user-facing metadata strings. * - * Runs `title`, `description`, `help`, and `keywords` through the widget - * i18n schema using the widget's `textdomain`, leaving every other key + * Runs `title`, `description`, `help`, `actions`, and `keywords` through the + * widget i18n schema using the widget's `textdomain`, leaving every other key * untouched. A no-op when the widget declares no `textdomain`. * * @param array $widget Widget data from the build manifest. @@ -55,7 +55,7 @@ function gutenberg_translate_widget_metadata( $widget ) { $i18n_schema = gutenberg_get_widget_metadata_i18n_schema(); - foreach ( array( 'title', 'description', 'help', 'keywords' ) as $field ) { + foreach ( array( 'title', 'description', 'help', 'actions', 'keywords' ) as $field ) { if ( isset( $widget[ $field ], $i18n_schema->$field ) ) { $widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema->$field, $widget[ $field ], $textdomain ); } @@ -106,6 +106,52 @@ function gutenberg_sanitize_widget_help( $help ) { return $sanitized; } +/** + * Constrains a widget's actions to their allowed shape: each entry keeps + * `id`, `label`, and `href`; entries missing any of those are dropped. + * Optional `download` and `openInNewTab` are copied when present. + * + * @param array|null $actions Actions from the build manifest. + * @return array|null Sanitized actions, or null when there are none. + */ +function gutenberg_sanitize_widget_actions( $actions ) { + if ( ! is_array( $actions ) ) { + return null; + } + + $sanitized = array(); + foreach ( $actions as $action ) { + if ( + ! is_array( $action ) || + empty( $action['id'] ) || + empty( $action['label'] ) || + empty( $action['href'] ) + ) { + continue; + } + + $entry = array( + 'id' => $action['id'], + 'label' => $action['label'], + 'href' => $action['href'], + ); + + if ( isset( $action['download'] ) ) { + $entry['download'] = is_bool( $action['download'] ) + ? $action['download'] + : (string) $action['download']; + } + + if ( isset( $action['openInNewTab'] ) ) { + $entry['openInNewTab'] = (bool) $action['openInNewTab']; + } + + $sanitized[] = $entry; + } + + return $sanitized ? $sanitized : null; +} + /** * Hydrates the widget type registry from the build manifest. * @@ -139,6 +185,7 @@ function gutenberg_register_widget_types() { 'title' => $widget['title'] ?? null, 'description' => $widget['description'] ?? null, 'help' => gutenberg_sanitize_widget_help( $widget['help'] ?? null ), + 'actions' => gutenberg_sanitize_widget_actions( $widget['actions'] ?? null ), 'keywords' => $widget['keywords'] ?? null, ) ); diff --git a/packages/widget-dashboard/CHANGELOG.md b/packages/widget-dashboard/CHANGELOG.md index 2cc2bcda6bfede..4c0c86f2f8a581 100644 --- a/packages/widget-dashboard/CHANGELOG.md +++ b/packages/widget-dashboard/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### New Features + +- Surface a widget's declared `actions` in the tile chrome as a "More" + menu of links ([#80363](https://github.com/WordPress/gutenberg/pull/80363)). + ### Enhancements - Widget settings: use the `drawerRight` icon for the per-tile settings diff --git a/packages/widget-dashboard/src/components/widget-actions/index.ts b/packages/widget-dashboard/src/components/widget-actions/index.ts new file mode 100644 index 00000000000000..99b67d2b68855b --- /dev/null +++ b/packages/widget-dashboard/src/components/widget-actions/index.ts @@ -0,0 +1 @@ +export { WidgetActions } from './widget-actions'; diff --git a/packages/widget-dashboard/src/components/widget-actions/widget-actions.module.css b/packages/widget-dashboard/src/components/widget-actions/widget-actions.module.css new file mode 100644 index 00000000000000..a90dc1c24ba5b0 --- /dev/null +++ b/packages/widget-dashboard/src/components/widget-actions/widget-actions.module.css @@ -0,0 +1,3 @@ +.widget-action-items .widget-action-link { + display: inline-flex; +} diff --git a/packages/widget-dashboard/src/components/widget-actions/widget-actions.tsx b/packages/widget-dashboard/src/components/widget-actions/widget-actions.tsx new file mode 100644 index 00000000000000..78149eddc055f8 --- /dev/null +++ b/packages/widget-dashboard/src/components/widget-actions/widget-actions.tsx @@ -0,0 +1,79 @@ +/** + * WordPress dependencies + */ +import { privateApis as componentsPrivateApis } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { moreVertical } from '@wordpress/icons'; +// eslint-disable-next-line @wordpress/use-recommended-components +import { IconButton, Link } from '@wordpress/ui'; +import type { WidgetType } from '@wordpress/widget-primitives'; + +/** + * Internal dependencies + */ +import styles from './widget-actions.module.css'; + +import { unlock } from '../../lock-unlock'; + +const { Menu } = unlock( componentsPrivateApis ); + +type WidgetActionsProps = { + /** + * The widget type whose declared actions render here. + */ + widgetType: WidgetType; +}; + +/** + * Materializes a widget type's declared `actions` as a "more" menu in the + * chrome: a three-dots trigger surfacing each action as a link. Each action + * is a declarative link target; the host renders it as an anchor and owns + * placement. + * + * @param {WidgetActionsProps} props Component props. + */ +export function WidgetActions( { + widgetType, +}: WidgetActionsProps ): React.ReactNode { + const actions = widgetType.actions ?? []; + + if ( actions.length === 0 ) { + return null; + } + + return ( + + + } + /> + + + + { actions.map( ( action ) => ( + + } + > + { action.label } + + ) ) } + + + + ); +} diff --git a/packages/widget-dashboard/src/components/widgets/widgets.tsx b/packages/widget-dashboard/src/components/widgets/widgets.tsx index 9e0aa8c55c109a..01510462d340a3 100644 --- a/packages/widget-dashboard/src/components/widgets/widgets.tsx +++ b/packages/widget-dashboard/src/components/widgets/widgets.tsx @@ -21,6 +21,7 @@ import type { WidgetName } from '@wordpress/widget-primitives'; */ import { useDashboardInternalContext } from '../../context/dashboard-context'; import { useDashboardContainerColumnCount } from '../../hooks/use-dashboard-container-column-count'; +import { WidgetActions } from '../widget-actions'; import { WidgetAttributeControls } from '../widget-attribute-controls'; import { WidgetChrome } from '../widget-chrome'; import { WidgetHeader } from '../widget-header'; @@ -134,21 +135,31 @@ export const Widgets = forwardRef< HTMLDivElement, WidgetsProps >( ( type ) => type.name === widget.type ); const hasSettings = !! widgetType?.attributes?.length; + const hasActions = !! widgetType?.actions?.length; + const isFullBleed = widgetType?.presentation === 'full-bleed'; // The active mode's controls: layout while customizing, the // attribute controls (high-relevance fields on the prominent - // surface, plus a settings entry point when needed) - // otherwise. + // surface, plus a settings entry point when needed) and the + // declared actions otherwise. let controls: React.ReactNode; if ( editMode ) { controls = ; - } else if ( hasSettings && widgetType ) { + } else if ( ( hasSettings || hasActions ) && widgetType ) { controls = ( - + <> + { hasSettings && ( + + ) } + + { hasActions && ( + + ) } + ); } diff --git a/packages/widget-dashboard/src/stories/index.story.tsx b/packages/widget-dashboard/src/stories/index.story.tsx index c65ca83ec05769..8d886290e84d6f 100644 --- a/packages/widget-dashboard/src/stories/index.story.tsx +++ b/packages/widget-dashboard/src/stories/index.story.tsx @@ -16,6 +16,7 @@ import { useState } from '@wordpress/element'; import { chartBar, trendingUp } from '@wordpress/icons'; import type { ResolveWidgetModule, + WidgetAction, WidgetAttributeField, WidgetRenderProps, WidgetType, @@ -139,6 +140,10 @@ const trafficSnapshotWidgetType: WidgetType = { title: 'Traffic Snapshot', description: 'Sample metric widget used to exercise the inline attribute controls.', + help: { + content: + 'Three attributes. Metric and Period are high relevance — two fields in the header. Label is low, behind the settings button.', + }, icon: chartBar, renderModule: 'demo/widgets/traffic-snapshot/render', attributes: SNAPSHOT_FIELDS as WidgetType[ 'attributes' ], @@ -256,14 +261,35 @@ const GOAL_FIELDS: WidgetAttributeField< GoalAttributes >[] = [ }, ]; +// Two declarative actions: an external link and a client-side download. +const GOAL_ACTIONS: WidgetAction[] = [ + { + id: 'view-goal', + label: 'View goal details', + href: 'https://wordpress.org/', + openInNewTab: true, + }, + { + id: 'export-progress', + label: 'Export progress', + href: 'data:text/csv;charset=utf-8,metric,target,current,percent%0Arevenue,5000,3600,72', + download: 'goal-progress.csv', + }, +]; + const goalProgressWidgetType: WidgetType = { apiVersion: 1, name: 'demo/goal-progress', title: 'Goal Progress', description: 'Sample goal widget whose attributes are all promoted.', + help: { + content: + 'Two attributes, Goal metric and Target, both high relevance — two fields in the header, no settings button. Two actions in the More menu: a link and a download.', + }, icon: trendingUp, renderModule: 'demo/widgets/goal-progress/render', attributes: GOAL_FIELDS as WidgetType[ 'attributes' ], + actions: GOAL_ACTIONS, example: { attributes: { metric: 'revenue', target: '5000' }, }, @@ -360,9 +386,13 @@ Two demo types exercise that policy: Their tiles compare the header presentations: - The two-column tile fits the identity and both inline controls, so they stay in the header. -- The one-column tiles do not: the promoted fields collapse into a dropdown holding them as a form, while the settings trigger stays in the toolbar beside it. The goal tile, with every attribute promoted, shows only the dropdown. +- The one-column tiles do not: the promoted fields collapse into a dropdown holding them as a form, while the settings trigger stays in the toolbar beside it. The goal tile, with every attribute promoted, shows the dropdown next to its actions menu. The widget only declares relevance; the fit is measured by the chrome, so the same declaration adapts to any tile width. Resize the canvas to watch the headers switch presentations. + +Beyond attributes, \`demo/goal-progress\` declares two \`actions\`: a "View goal details" link and an "Export progress" download. The host surfaces them in a "More" menu in the toolbar, so the widget declares each action as data and the host owns where it appears. + +Each type also carries a \`help\` note, opened from the info icon in the header, that describes its attributes and what they do. `, }, }, diff --git a/packages/widget-primitives/CHANGELOG.md b/packages/widget-primitives/CHANGELOG.md index 37d1e66e207899..6f7097546f0577 100644 --- a/packages/widget-primitives/CHANGELOG.md +++ b/packages/widget-primitives/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### New Features + +- `WidgetTypeMetadata`: add optional `actions`, a declarative list of + user-triggerable links a widget exposes ([#80363](https://github.com/WordPress/gutenberg/pull/80363)). + ### Enhancements - Ship the package as a WordPress script module @@ -9,6 +14,8 @@ ### Documentation +- Add an Actions doc page and a `WithActions` story, and cover `actions` + in the widget anatomy doc ([#80363](https://github.com/WordPress/gutenberg/pull/80363)). - Spell out the accepted field-type name syntax: lowercase kebab-case segments, with at most one namespace level ([#80208](https://github.com/WordPress/gutenberg/pull/80208)). diff --git a/packages/widget-primitives/README.md b/packages/widget-primitives/README.md index b2070f363c079a..d336329905cf0e 100644 --- a/packages/widget-primitives/README.md +++ b/packages/widget-primitives/README.md @@ -43,30 +43,37 @@ the hook in its loading state: `widgetTypes` is empty and ## Public API -- ``: entry point for any host that mounts a widget. It - resolves the widget's render module via a host-provided - `resolveWidgetModule` and mounts the resulting component with the - `attributes` / `setAttributes` render contract. Error handling and chrome - stay with the host, which wraps the lazy render in a `Suspense` boundary. -- `useWidgetTypes( records )` → `[ widgetTypes, isResolvingWidgetTypes ]`: - takes host-supplied records (`WidgetModuleRecord[]`, or `null` while - loading) and imports each record's metadata module; - `isResolvingWidgetTypes` stays `true` until they resolve. -- Contract types: `WidgetType`, `WidgetName`, `WidgetIcon`, - `WidgetRenderProps`, `ResolveWidgetModule`, `WidgetModuleRecord`. - `WidgetIcon` is a rendered SVG element; hosts pass it to their icon - primitive as is. -- `WidgetAttributeField< Item >`: authoring helper. It is a DataViews - `Field` whose `id` is narrowed to the keys of the widget's attribute - object, with an optional `relevance` hint (`'high' | 'low'`) marking - attributes a host may promote to a prominent surface. -- Field types: `registerFieldType( definition )` names a reusable - field type (`{ name: 'location', baseType: 'text', Edit, ... }`, - typed by `FieldTypeDefinition`) that attributes can reference via - `type`. `useWidgetTypes` resolves such references into the plain - per-field `Field` props DataViews understands, inheriting everything - else from `baseType`. Names that are not registered degrade exactly - as unknown types do in DataViews. +### `` + +It's the entry point for any host that mounts a widget. It resolves the render module via a host-provided `resolveWidgetModule` and mounts the component using the `attributes` / `setAttributes` contract. + +Error handling and chrome stay with the host, which wraps the lazy render in a `Suspense` boundary. + +### `useWidgetTypes( records )` + +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. + +### Contract types + +`WidgetType`, `WidgetName`, `WidgetIcon`, `WidgetRenderProps`, `ResolveWidgetModule`, and `WidgetModuleRecord`. `WidgetIcon` is a rendered SVG element that hosts the pass to its icon primitive as-is. + +### `WidgetAttributeField< Item >` + +It's an authoring helper: a DataViews `Field` whose `id` is narrowed to the widget's attribute keys. +Its optional `relevance` hint (`'high' | 'low'`) marks attributes a host may promote to a prominent surface. + +### `WidgetAction` + +It's a declarative action a widget type exposes. +Each carries `id`, `label`, `href`, and optional `download` / `openInNewTab`. + +The widget names the intent and a link target; the host renders it as an anchor and owns placement. Only the `link` target exists today, and navigation and downloads are handled by the browser. + +### Field types + +`registerFieldType( definition )` names a reusable field type — `{ name: 'location', baseType: 'text', Edit, ... }`, typed by `FieldTypeDefinition`. Those attributes reference via `type`. + +`useWidgetTypes` resolves those references into the plain per-field `Field` props DataViews understands, inheriting the rest from `baseType`. ## Architecture @@ -76,15 +83,8 @@ hosts), see the ## Contributing to this package -This is an individual package that's part of the Gutenberg project. -The project is organized as a monorepo. It's made up of multiple -self-contained software packages, each with a specific purpose. The -packages in this monorepo are published to [npm](https://www.npmjs.com/) -and used by [WordPress](https://make.wordpress.org/core/) as well as -other software projects. +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. -To find out more about contributing to this package or Gutenberg as a -whole, please read the project's main -[contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md).

Code is Poetry.

diff --git a/packages/widget-primitives/src/components/widget-render/stories/index.story.tsx b/packages/widget-primitives/src/components/widget-render/stories/index.story.tsx index b1bce0bf944275..71ef6d1691ec3c 100644 --- a/packages/widget-primitives/src/components/widget-render/stories/index.story.tsx +++ b/packages/widget-primitives/src/components/widget-render/stories/index.story.tsx @@ -18,7 +18,7 @@ import { Suspense, useId, useMemo, useState } from '@wordpress/element'; import { globe, starFilled } from '@wordpress/icons'; // `IconButton` is not on the recommended list yet. /* eslint-disable @wordpress/use-recommended-components */ -import { Card, Icon, IconButton, Stack } from '@wordpress/ui'; +import { Card, Icon, IconButton, Link, Stack } from '@wordpress/ui'; /* eslint-enable @wordpress/use-recommended-components */ /** @@ -27,6 +27,7 @@ import { Card, Icon, IconButton, Stack } from '@wordpress/ui'; import { WidgetRender } from '..'; import { registerFieldType, resolveFields } from '../../../field-types'; import type { + WidgetAction, WidgetAttributeField, WidgetRenderProps, WidgetType, @@ -747,3 +748,97 @@ An unregistered name would degrade silently, exactly like an unknown type in Dat }, }, }; + +const actionsWidgetType: WidgetType< DemoAttributes > = { + ...demoWidgetType, + actions: [ + { + id: 'open-docs', + label: 'Open docs', + href: 'https://developer.wordpress.org/', + openInNewTab: true, + }, + { + id: 'export-greeting', + label: 'Export greeting', + href: 'data:text/plain;charset=utf-8,Hello%20World', + download: 'greeting.txt', + }, + ] satisfies WidgetAction[], +}; + +function WidgetWithActions() { + const titleId = useId(); + const actions = actionsWidgetType.actions ?? []; + const [ attributes ] = useState< DemoAttributes >( { + ...actionsWidgetType.example?.attributes, + } ); + + return ( +
+ } aria-labelledby={ titleId }> + + + { actionsWidgetType.icon && ( + + ) } + } + style={ { flexGrow: 1 } } + > + { actionsWidgetType.title } + + + { /* The host materializes the declared actions; here, as links. */ } + + { actions.map( ( action ) => ( + + { action.label } + + ) ) } + + + + + + + widgetType={ actionsWidgetType } + attributes={ attributes } + resolveWidgetModule={ resolveDemoModule } + /> + + + +
+ ); +} + +export const WithActions: StoryObj = { + render: () => , + parameters: { + docs: { + description: { + story: ` +Beyond its data, a widget can declare \`actions\`: verbs a user can trigger, like opening docs or downloading a file. The widget names each action (\`id\`, \`label\`, and a link \`href\`); the host decides where to put it and materializes it. + +**In this demo** + +- The widget declares two actions: an external link (\`Open docs\`) and a download (\`Export greeting\`). +- The host renders them as links beside the title. Another host could surface them in a menu, a footer, or a command palette. + +**Takeaway** + +The widget names the intent and a link target; navigation and download are the browser's, so the widget never knows its surface. +`, + }, + }, + }, +}; diff --git a/packages/widget-primitives/src/hooks/use-widget-types.ts b/packages/widget-primitives/src/hooks/use-widget-types.ts index d687218c49f9a1..dcdd0a7f03ed73 100644 --- a/packages/widget-primitives/src/hooks/use-widget-types.ts +++ b/packages/widget-primitives/src/hooks/use-widget-types.ts @@ -94,6 +94,9 @@ export function useWidgetTypes( ...( record.keywords ? { keywords: record.keywords } : {} ), + ...( record.actions + ? { actions: record.actions } + : {} ), } as WidgetType; } catch { return null; diff --git a/packages/widget-primitives/src/index.ts b/packages/widget-primitives/src/index.ts index c1fcff43675ea1..e7e42bbcf56414 100644 --- a/packages/widget-primitives/src/index.ts +++ b/packages/widget-primitives/src/index.ts @@ -20,6 +20,7 @@ export type { WidgetName, WidgetIcon, WidgetType, + WidgetAction, WidgetAttributeField, WidgetRenderProps, ResolveWidgetModule, diff --git a/packages/widget-primitives/src/stories/actions.md b/packages/widget-primitives/src/stories/actions.md new file mode 100644 index 00000000000000..d89454b80e116f --- /dev/null +++ b/packages/widget-primitives/src/stories/actions.md @@ -0,0 +1,44 @@ +# Actions + +
+This package is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+ +A widget declares its data through `attributes`; it declares the verbs a user can trigger through `actions`. + +An action is something to _do_: open a report, download a file, etc. Like other widgets' props, it's declarative and serializable, so it can ride in `widget.json`. + +The widget names the intent and a target; the host decides where the action appears and how it materializes. + +## Envelope and fulfillment + +Every action carries an **envelope**: an `id` and a `label`, and one **fulfillment** that says what it triggers. +Today, the only fulfillment is a `link`: a target the host renders as an anchor. + +```ts +{ + id: 'view-report', + label: __( 'View report' ), + href: 'admin.php?page=reports', +} +``` + +`href` is a complete URL or path; external URLs and admin entry points load a full page. Two optional flags refine it: + +- `download`: turns the target into a file download; a string names the file. +- `openInNewTab`: opens the target in a new browser tab. + +```ts +{ + id: 'export', + label: __( 'Export CSV' ), + href: reportCsvUrl, + download: 'report.csv', +} +``` + +## Placement is the host's + +The widget lists its actions; it never specifies where they go. The host maps them to its surfaces: a dashboard might gather them in a "More" menu, a footer, or a command palette. + +This is the contract that the `relevance` attribute already uses: the widget declares intent, and the host owns the surface. \ No newline at end of file diff --git a/packages/widget-primitives/src/stories/actions.mdx b/packages/widget-primitives/src/stories/actions.mdx new file mode 100644 index 00000000000000..a6af89fe3c66b4 --- /dev/null +++ b/packages/widget-primitives/src/stories/actions.mdx @@ -0,0 +1,6 @@ +import { Meta, Markdown } from '@storybook/addon-docs/blocks'; +import ActionsDoc from './actions.md?raw'; + + + +{ ActionsDoc } diff --git a/packages/widget-primitives/src/stories/anatomy.md b/packages/widget-primitives/src/stories/anatomy.md index 41056bfcceaf46..152bcdcb4a6bbe 100644 --- a/packages/widget-primitives/src/stories/anatomy.md +++ b/packages/widget-primitives/src/stories/anatomy.md @@ -41,14 +41,14 @@ Framing is the layer the host translates, and the one place where the same decla ## Representation -How the widget represents its data: `attributes`, `example`, and the render module. +How the widget represents its data and the verbs it exposes: `attributes`, `actions`, `example`, and the render module. The `attributes` are the contract between host and widget. The widget owns their shape and meaning; the host owns their values. Each entry in `attributes` is a DataViews `Field` plus an optional `relevance` hint (`'high' | 'low'`). Its `type` may also reference a registered field type by name, resolved to plain `Field` props before any host reads the schema (see **Field Types**). The widget declares how important an attribute is; the host decides where, and whether, to expose it. When the hint is absent, treat it as `'low'`. -- `'high'`: the host _may_ promote the field to a prominent surface near the instance, for quick in-context editing without opening a separate settings UI. -- `'low'` (default): the field belongs in the host's settings surface, a form built from the full schema. +- `'high'`: the host _may_ promote the field to a prominent surface near the instance, for quick in-context editing without opening a separate settings UI. +- `'low'` (default): the field belongs in the host's settings surface, a form built from the full schema. The widget names importance, not a surface. One host might promote `'high'` fields inline; another might fold everything into a single panel. Both honor the same contract. @@ -60,6 +60,10 @@ Either way the host never interprets the values. It mounts the form from the dec ![The attributes are a contract both sides write: the render module reads them to produce the output, the widget asks for changes through setAttributes, and the host edits them through a settings surface. The meaning stays the widget's.](./assets/representation.svg) +Alongside its data, a widget can declare `actions`: the verbs a user triggers, such as opening a report or downloading a file. Each action names an `id`, a `label`, and a link target (`href`, with optional `download` / `openInNewTab`). + +The pattern matches attributes: the widget declares the intent, and the host decides the surface. It never says "put this in a toolbar"; it names the action, and the host materializes the affordance and places it. Today the only target is a link, so navigation and download are the browser's, and the host renders the anchor without interpreting what the action means. + ## Why the split matters Each layer is consumed by one verb: identity is _copied_, framing is _translated_, representation is _rendered_. Each verb is a boundary of ownership. diff --git a/packages/widget-primitives/src/types.ts b/packages/widget-primitives/src/types.ts index 6e57edd915a7d5..7bb31f04a84225 100644 --- a/packages/widget-primitives/src/types.ts +++ b/packages/widget-primitives/src/types.ts @@ -70,6 +70,41 @@ export interface WidgetHelp { */ type WidgetAttributeRelevance = 'high' | 'low'; +/** + * A user-triggerable action a widget type declares. The declaration is + * serializable data; the host materializes it as an affordance and owns + * placement. The action points at a `link` target the host renders as an + * anchor; `download` turns it into a file download. + */ +export interface WidgetAction { + /** + * Stable identifier, local to the widget type. + */ + id: string; + + /** + * Human-readable label naming the action. Translatable. + */ + label: string; + + /** + * Destination the action points at. External URLs and admin PHP entry + * points load a full page. + */ + href: string; + + /** + * When set, the browser downloads the destination instead of navigating. + * A string supplies the suggested filename. + */ + download?: string | boolean; + + /** + * Whether the destination opens in a new browser tab. + */ + openInNewTab?: boolean; +} + /** * A DataViews `Field` plus the widget-layer `relevance` hint; what hosts * read. Its `type` may also reference a registered field type by name @@ -177,6 +212,12 @@ export interface WidgetTypeMetadata< Item = unknown > { */ attributes?: WidgetAttribute< Item >[]; + /** + * Declarative actions the widget type exposes. Hosts materialize each + * one as an affordance and decide where to place it. + */ + actions?: WidgetAction[]; + /** * Structured example data hosts use for previews, and the default * attributes applied when a new instance is created without initial @@ -253,6 +294,7 @@ type WidgetModuleRecordOverrides = { | 'category' | 'presentation' | 'keywords' + | 'actions' > ]?: WidgetTypeMetadata[ K ] | null; }; diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index b2b1a86856ea91..4f20b0573024d7 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Enhancements + +- Widgets: carry a widget's declarative `actions` from `widget.json` into + the generated PHP registry ([#80363](https://github.com/WordPress/gutenberg/pull/80363)). + ## 0.19.0 (2026-07-14) ### Enhancements diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index f079b9712712f0..780d4352df0de1 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -2016,7 +2016,7 @@ async function buildAllWidgets() { * Discover all widgets and collect their registry-facing data. * Widgets without a valid widget.json are skipped. * - * @return {Array<{ name: string, dirName: string, title: string | null, description: string | null, help: import('./widget-utils.mjs').WidgetHelpMetadata | null, hasRender: boolean, hasWidget: boolean, presentation: string | null, category: string | null, keywords: string[] | null, textdomain: string | null }>} Array of widget objects. + * @return {Array<{ name: string, dirName: string, title: string | null, description: string | null, help: import('./widget-utils.mjs').WidgetHelpMetadata | null, actions: import('./widget-utils.mjs').WidgetActionMetadata[] | null, hasRender: boolean, hasWidget: boolean, presentation: string | null, category: string | null, keywords: string[] | null, textdomain: string | null }>} Array of widget objects. */ function collectWidgets() { return getAllWidgets( ROOT_DIR ).flatMap( ( widgetName ) => { @@ -2038,6 +2038,7 @@ function collectWidgets() { title: metadata.title ?? null, description: metadata.description ?? null, help: metadata.help ?? null, + actions: metadata.actions ?? null, hasRender: widgetFiles.hasRender, hasWidget: widgetFiles.hasWidget, presentation: metadata.presentation ?? null, @@ -2114,6 +2115,60 @@ function toPhpHelpLiteral( help ) { return `array( ${ parts.join( ', ' ) } )`; } +/** + * Format a widget's actions as a PHP array literal. Returns the PHP literal + * `null` when there are no valid actions; entries missing `id`, `label`, or + * `href` are dropped. + * + * @param {import('./widget-utils.mjs').WidgetActionMetadata[]|null|undefined} actions Source value. + * @return {string} PHP array literal, or `null`. + */ +function toPhpActionsLiteral( actions ) { + if ( ! Array.isArray( actions ) ) { + return 'null'; + } + + const entries = actions + .filter( + ( action ) => action && action.id && action.label && action.href + ) + .map( ( action ) => { + const parts = [ + `'id' => ${ toPhpStringLiteral( action.id ) }`, + `'label' => ${ toPhpStringLiteral( action.label ) }`, + `'href' => ${ toPhpStringLiteral( action.href ) }`, + ]; + + if ( action.download !== undefined ) { + parts.push( + typeof action.download === 'boolean' + ? `'download' => ${ + action.download ? 'true' : 'false' + }` + : `'download' => ${ toPhpStringLiteral( + action.download + ) }` + ); + } + + if ( action.openInNewTab !== undefined ) { + parts.push( + `'openInNewTab' => ${ + action.openInNewTab ? 'true' : 'false' + }` + ); + } + + return `array( ${ parts.join( ', ' ) } )`; + } ); + + if ( entries.length === 0 ) { + return 'null'; + } + + return `array( ${ entries.join( ', ' ) } )`; +} + /** * Generate global widget registry file. * Creates a single registry with all widgets including file availability. @@ -2139,6 +2194,7 @@ async function generateWidgetRegistry( widgets, replacements ) { const titleStr = toPhpStringLiteral( widget.title ); const descriptionStr = toPhpStringLiteral( widget.description ); const helpStr = toPhpHelpLiteral( widget.help ); + const actionsStr = toPhpActionsLiteral( widget.actions ); const keywordsStr = toPhpStringArrayLiteral( widget.keywords ); const textdomainStr = toPhpStringLiteral( widget.textdomain ); return `\tarray( @@ -2147,6 +2203,7 @@ async function generateWidgetRegistry( widgets, replacements ) { 'title' => ${ titleStr }, 'description' => ${ descriptionStr }, 'help' => ${ helpStr }, + 'actions' => ${ actionsStr }, 'has_render' => ${ hasRenderStr }, 'has_widget' => ${ hasWidgetStr }, 'presentation' => ${ presentationStr }, diff --git a/packages/wp-build/lib/widget-utils.mjs b/packages/wp-build/lib/widget-utils.mjs index d22668c8508d5c..f67814b3f42ab3 100644 --- a/packages/wp-build/lib/widget-utils.mjs +++ b/packages/wp-build/lib/widget-utils.mjs @@ -35,12 +35,22 @@ export function getAllWidgets( rootDir ) { * @property {WidgetHelpLinkMetadata[]} [links] Links contextual to the note. */ +/** + * @typedef {Object} WidgetActionMetadata + * @property {string} id Stable identifier, local to the widget type. + * @property {string} label Human-readable label. Translatable. + * @property {string} href Destination the action points at. + * @property {string|boolean} [download] Download the destination; a string sets the filename. + * @property {boolean} [openInNewTab] Open the destination in a new browser tab. + */ + /** * @typedef {Object} WidgetMetadata * @property {string} name Widget namespaced identifier. * @property {string} [title] Human-readable title. * @property {string} [description] Short description. * @property {WidgetHelpMetadata} [help] Contextual help note for compact surfaces. + * @property {WidgetActionMetadata[]} [actions] Declarative actions the widget exposes. * @property {string} [category] Grouping category. * @property {'framed' | 'content-bleed' | 'full-bleed'} [presentation] Authoring intent about how the widget wants to render. * @property {string[]} [keywords] Search aliases used to match the widget. diff --git a/phpunit/experimental/class-wp-rest-widget-modules-controller-test.php b/phpunit/experimental/class-wp-rest-widget-modules-controller-test.php index 3a15c174742054..b431a25ad56304 100644 --- a/phpunit/experimental/class-wp-rest-widget-modules-controller-test.php +++ b/phpunit/experimental/class-wp-rest-widget-modules-controller-test.php @@ -71,6 +71,13 @@ private function register_sample_widgets() { ), ), ), + 'actions' => array( + array( + 'id' => 'open-settings', + 'label' => 'Open settings', + 'href' => 'options-general.php', + ), + ), 'keywords' => array( 'alpha', 'first' ), ) ); @@ -152,6 +159,16 @@ public function test_get_item_returns_widget_by_name() { ), $data['help'] ); + $this->assertSame( + array( + array( + 'id' => 'open-settings', + 'label' => 'Open settings', + 'href' => 'options-general.php', + ), + ), + $data['actions'] + ); $this->assertSame( array( 'alpha', 'first' ), $data['keywords'] ); } @@ -202,6 +219,7 @@ public function test_schema_exposes_expected_properties() { $this->assertArrayHasKey( 'title', $properties ); $this->assertArrayHasKey( 'description', $properties ); $this->assertArrayHasKey( 'help', $properties ); + $this->assertArrayHasKey( 'actions', $properties ); $this->assertArrayHasKey( 'keywords', $properties ); $this->assertSame( 'string', $properties['name']['type'] ); $this->assertSame( array( 'string', 'null' ), $properties['render_module']['type'] ); @@ -210,6 +228,7 @@ public function test_schema_exposes_expected_properties() { $this->assertSame( array( 'string', 'null' ), $properties['title']['type'] ); $this->assertSame( array( 'string', 'null' ), $properties['description']['type'] ); $this->assertSame( array( 'object', 'null' ), $properties['help']['type'] ); + $this->assertSame( array( 'array', 'null' ), $properties['actions']['type'] ); $this->assertSame( array( 'array', 'null' ), $properties['keywords']['type'] ); } } diff --git a/widgets/hello-dolly/widget.json b/widgets/hello-dolly/widget.json index eae8bc7586e96a..4a63da4eec7209 100644 --- a/widgets/hello-dolly/widget.json +++ b/widgets/hello-dolly/widget.json @@ -4,5 +4,19 @@ "description": "This is not just a widget, it symbolizes the hope and enthusiasm of an entire generation summed up in two words sung most famously by Louis Armstrong: Hello, Dolly. When activated you will randomly see a lyric from Hello, Dolly in the dashboard.", "category": "dashboard", "presentation": "full-bleed", + "actions": [ + { + "id": "plugin-page", + "label": "WordPress.org plugin page", + "href": "https://wordpress.org/plugins/hello-dolly/", + "openInNewTab": true + }, + { + "id": "download-lyrics", + "label": "Download lyrics", + "href": "data:text/plain;charset=utf-8,Hello%2C%20Dolly%0AWell%2C%20hello%2C%20Dolly%0AIt's%20so%20nice%20to%20have%20you%20back%20where%20you%20belong%0AYou're%20lookin'%20swell%2C%20Dolly%0AI%20can%20tell%2C%20Dolly%0AYou're%20still%20glowin'%2C%20you're%20still%20crowin'%0AYou're%20still%20goin'%20strong%0AI%20feel%20the%20room%20swayin'%0AWhile%20the%20band's%20playin'%0AOne%20of%20our%20old%20favorite%20songs%20from%20way%20back%20when%0ASo%2C%20take%20her%20wrap%2C%20fellas%0ADolly%2C%20never%20go%20away%20again%0AHello%2C%20Dolly%0AWell%2C%20hello%2C%20Dolly%0AIt's%20so%20nice%20to%20have%20you%20back%20where%20you%20belong%0AYou're%20lookin'%20swell%2C%20Dolly%0AI%20can%20tell%2C%20Dolly%0AYou're%20still%20glowin'%2C%20you're%20still%20crowin'%0AYou're%20still%20goin'%20strong%0AI%20feel%20the%20room%20swayin'%0AWhile%20the%20band's%20playin'%0AOne%20of%20our%20old%20favorite%20songs%20from%20way%20back%20when%0ASo%2C%20golly%2C%20gee%2C%20fellas%0AHave%20a%20little%20faith%20in%20me%2C%20fellas%0ADolly%2C%20never%20go%20away%0APromise%2C%20you'll%20never%20go%20away%0ADolly'll%20never%20go%20away%20again", + "download": "hello-dolly-lyrics.txt" + } + ], "textdomain": "default" }