Skip to content
Merged
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
10 changes: 6 additions & 4 deletions docs/explanations/architecture/dashboard-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`:
Expand All @@ -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`.
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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' ),
Expand Down
11 changes: 11 additions & 0 deletions lib/experimental/dashboard-widgets/class-wp-widget-type.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/experimental/dashboard-widgets/widget-i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"content": "widget help content",
"links": [ { "label": "widget help link label" } ]
},
"actions": [ { "label": "widget action label" } ],
"keywords": [ "widget keyword" ]
}
53 changes: 50 additions & 3 deletions lib/experimental/dashboard-widgets/widget-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 );
}
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
)
);
Expand Down
5 changes: 5 additions & 0 deletions packages/widget-dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WidgetActions } from './widget-actions';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.widget-action-items .widget-action-link {
display: inline-flex;
}
Original file line number Diff line number Diff line change
@@ -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 (
<Menu>
<Menu.TriggerButton
render={
<IconButton
icon={ moreVertical }
label={ __( 'More' ) }
variant="minimal"
tone="neutral"
size="compact"
/>
}
/>

<Menu.Popover>
<Menu.Group className={ styles[ 'widget-action-items' ] }>
{ actions.map( ( action ) => (
<Menu.Item
key={ action.id }
render={
<Link
href={ action.href }
download={ action.download }
openInNewTab={ action.openInNewTab }
className={ styles[ 'widget-action-link' ] }
/>
}
>
{ action.label }
</Menu.Item>
) ) }
</Menu.Group>
</Menu.Popover>
</Menu>
);
}
25 changes: 18 additions & 7 deletions packages/widget-dashboard/src/components/widgets/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = <WidgetLayoutControls widget={ widget } />;
} else if ( hasSettings && widgetType ) {
} else if ( ( hasSettings || hasActions ) && widgetType ) {
controls = (
<WidgetAttributeControls
widget={ widget }
widgetType={ widgetType }
/>
<>
{ hasSettings && (
<WidgetAttributeControls
widget={ widget }
widgetType={ widgetType }
/>
) }

{ hasActions && (
<WidgetActions widgetType={ widgetType } />
) }
</>
);
}

Expand Down
32 changes: 31 additions & 1 deletion packages/widget-dashboard/src/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useState } from '@wordpress/element';
import { chartBar, trendingUp } from '@wordpress/icons';
import type {
ResolveWidgetModule,
WidgetAction,
WidgetAttributeField,
WidgetRenderProps,
WidgetType,
Expand Down Expand Up @@ -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. <strong>Metric</strong> and <strong>Period</strong> are <strong>high</strong> relevance — two fields in the header. <strong>Label</strong> is <strong>low</strong>, behind the settings button.',
},
icon: chartBar,
renderModule: 'demo/widgets/traffic-snapshot/render',
attributes: SNAPSHOT_FIELDS as WidgetType[ 'attributes' ],
Expand Down Expand Up @@ -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, <strong>Goal metric</strong> and <strong>Target</strong>, both <strong>high</strong> relevance — two fields in the header, no settings button. Two actions in the <strong>More</strong> 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' },
},
Expand Down Expand Up @@ -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.
`,
},
},
Expand Down
Loading
Loading