diff --git a/package-lock.json b/package-lock.json index 283753b92a270a..47ce7d8b516ec1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63096,12 +63096,17 @@ "dependencies": { "@wordpress/admin-ui": "file:../../packages/admin-ui", "@wordpress/base-styles": "file:../../packages/base-styles", + "@wordpress/compose": "file:../../packages/compose", "@wordpress/data": "file:../../packages/data", "@wordpress/dataviews": "file:../../packages/dataviews", "@wordpress/element": "file:../../packages/element", + "@wordpress/grid": "file:../../packages/grid", "@wordpress/hooks": "file:../../packages/hooks", "@wordpress/i18n": "file:../../packages/i18n", - "@wordpress/warning": "file:../../packages/warning" + "@wordpress/icons": "file:../../packages/icons", + "@wordpress/ui": "file:../../packages/ui", + "@wordpress/warning": "file:../../packages/warning", + "clsx": "^2.1.1" } }, "routes/experiments-home": { diff --git a/routes/dashboard/package.json b/routes/dashboard/package.json index 1c5940764a0c9b..5b0385fcc35114 100644 --- a/routes/dashboard/package.json +++ b/routes/dashboard/package.json @@ -11,11 +11,16 @@ "dependencies": { "@wordpress/admin-ui": "file:../../packages/admin-ui", "@wordpress/base-styles": "file:../../packages/base-styles", + "@wordpress/compose": "file:../../packages/compose", "@wordpress/data": "file:../../packages/data", "@wordpress/dataviews": "file:../../packages/dataviews", "@wordpress/element": "file:../../packages/element", + "@wordpress/grid": "file:../../packages/grid", "@wordpress/hooks": "file:../../packages/hooks", "@wordpress/i18n": "file:../../packages/i18n", - "@wordpress/warning": "file:../../packages/warning" + "@wordpress/icons": "file:../../packages/icons", + "@wordpress/ui": "file:../../packages/ui", + "@wordpress/warning": "file:../../packages/warning", + "clsx": "^2.1.1" } } diff --git a/routes/dashboard/stage.tsx b/routes/dashboard/stage.tsx index 6e0ec03a64cfc5..2fc0e35ef19619 100644 --- a/routes/dashboard/stage.tsx +++ b/routes/dashboard/stage.tsx @@ -2,22 +2,39 @@ * WordPress dependencies */ import { Page } from '@wordpress/admin-ui'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { useWidgetTypes } from './widget-types/hooks/use-widget-types'; +import { WidgetDashboard, type DashboardWidget } from './widget-dashboard'; +import { useWidgetTypes } from './widget-types'; + +const DEFAULT_LAYOUT: DashboardWidget[] = [ + { + uuid: '1', + type: 'wordpress/hello-world', + placement: { + width: 'full', + height: 1, + }, + }, +]; function Dashboard() { - const widgetTypes = useWidgetTypes(); + const [ layout, setLayout ] = + useState< DashboardWidget[] >( DEFAULT_LAYOUT ); - // eslint-disable-next-line no-console - console.log( 'widgetTypes', widgetTypes ); // ToDo: clean after testing + const widgetTypes = useWidgetTypes(); return ( - -
+ + ); } diff --git a/routes/dashboard/tsconfig.json b/routes/dashboard/tsconfig.json index 93169299478915..ae3abe9d2d4bf8 100644 --- a/routes/dashboard/tsconfig.json +++ b/routes/dashboard/tsconfig.json @@ -1,11 +1,15 @@ { "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", + "rootDir": ".", "noEmit": true, - "resolveJsonModule": true, - "noImplicitAny": false + "emitDeclarationOnly": false, + "composite": false, + "noImplicitAny": false, + "types": [ "style-imports" ] }, "include": [ "**/*.ts", "**/*.tsx" ], - "exclude": [ "**/test/**" ] + "exclude": [ "**/test/**", "build", "node_modules" ] } diff --git a/routes/dashboard/widget-dashboard/README.md b/routes/dashboard/widget-dashboard/README.md new file mode 100644 index 00000000000000..b79efe34da51cb --- /dev/null +++ b/routes/dashboard/widget-dashboard/README.md @@ -0,0 +1,110 @@ +# `WidgetDashboard` + +Stateless rendering engine for widget dashboards. Renders an editable grid of widget instances, with drag-to-reorder and resize when edit mode is on. +Widget types flow in as a prop and every layout mutation fires `onLayoutChange` with the fully updated array. +The engine owns no data of its own. + +## Usage + +```tsx +import { useState } from '@wordpress/element'; +import { WidgetDashboard } from './widget-dashboard'; + +function Dashboard() { + const [ layout, setLayout ] = useState( defaultLayout ); + + return ( + + ); +} +``` + +`` renders `` by default. Pass `children` to compose the surface — header, empty state, footer — around the grid: + +```tsx + + +

{ __( 'No widgets yet.' ) }

+
+ +
+``` + +## Properties + +#### `layout`: `DashboardWidget[]` + +Widget instances to render. Each instance carries a stable `uuid`, a `type` reference, optional `attributes`, and a `placement` describing its slot in the grid. + +#### `onLayoutChange`: `( layout: DashboardWidget[] ) => void` + +Called on every mutation — reorder, resize, or `setAttributes` from a widget render module. Receives the fully updated array; the consumer owns the storage. + +#### `widgetTypes`: `WidgetType[]` + +The widget types available to the dashboard. + +#### `editMode`: `boolean` + +When `true`, the grid enables drag and resize. Defaults to `false`. + +#### `onEditChange`: `( next: boolean ) => void` + +Optional. Called when edit mode toggles via a future `WidgetDashboard.Actions` compound. + +#### `resolveWidgetModule`: `( moduleId: string ) => Promise< { default: ComponentType } >` + +Optional. Maps a `WidgetType.renderModule` id to the React component that renders the widget. Defaults to a dynamic `import( /* webpackIgnore */ moduleId )`. Override for tests, Storybook, or remote-URL loading. + +#### `gridSettings`: `WidgetGridSettings` + +Optional. Configures the underlying grid. + +#### `children`: `ReactNode` + +Optional. Composition slot for arbitrary surface markup. When omitted, the engine renders `` directly. + +## Compound components + +#### `` + +Iterates `layout`, renders each entry through ``, and feeds the resulting tree into the underlying grid (`@wordpress/grid`). + +#### `` + +Per-instance wrapper. Provides widget identity to the render tree via context and hosts the widget's render module under a `Suspense` boundary and an error boundary. The instance is read from `layout`; consumers don't pass it manually. + +#### `` + +Renders its children only when `layout` is empty. Pair it with `` so the empty state shows up in place of the grid until widgets are added. + +## Authoring widgets + +Widget render modules receive only what they need to render and edit: + +```ts +interface WidgetRenderProps< Item = unknown > { + attributes: Item; + setAttributes?: ( next: Partial< Item > ) => void; +} +``` + +`setAttributes` flows back through `onLayoutChange` on the dashboard. Removal, badges, and error chrome are not part of this contract — those belong to the surface. + +## Types + +- `DashboardWidget` — a placement of a widget on the dashboard. Carries `uuid`, `type`, `attributes`, `placement`. +- `WidgetType` — runtime widget type. Extends the `widget.json` shape with `renderModule`. +- `WidgetRenderProps` — widget render contract. +- `ResolveWidgetModule` — module resolver signature. +- `WidgetGridSettings` — grid configuration. + +`WidgetName`, `WidgetTypeMetadata`, and `WidgetType` are declared locally in `types.ts` until `@wordpress/widget-types` lands in trunk; at that point those three collapse into a re-export from the package. diff --git a/routes/dashboard/widget-dashboard/components/no-widgets-state/index.ts b/routes/dashboard/widget-dashboard/components/no-widgets-state/index.ts new file mode 100644 index 00000000000000..bce492e3cf4421 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/no-widgets-state/index.ts @@ -0,0 +1 @@ +export { NoWidgetsState } from './no-widgets-state'; diff --git a/routes/dashboard/widget-dashboard/components/no-widgets-state/no-widgets-state.module.css b/routes/dashboard/widget-dashboard/components/no-widgets-state/no-widgets-state.module.css new file mode 100644 index 00000000000000..669df43cd3dbc6 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/no-widgets-state/no-widgets-state.module.css @@ -0,0 +1,3 @@ +.root { + padding-block-start: calc(var(--wpds-dimension-padding-3xl) * 3.5); +} diff --git a/routes/dashboard/widget-dashboard/components/no-widgets-state/no-widgets-state.tsx b/routes/dashboard/widget-dashboard/components/no-widgets-state/no-widgets-state.tsx new file mode 100644 index 00000000000000..969f9da4aba254 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/no-widgets-state/no-widgets-state.tsx @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { home } from '@wordpress/icons'; +import { EmptyState, Stack } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import { useDashboardInternalContext } from '../../context/dashboard-context'; +import styles from './no-widgets-state.module.css'; + +export interface NoWidgetsStateProps { + children?: ReactNode; +} + +function NoWidgetsStateImpl( { children }: NoWidgetsStateProps ) { + const { layout } = useDashboardInternalContext(); + if ( layout.length > 0 ) { + return null; + } + + return ( + + { children ?? ( + + + + { __( 'Your dashboard is empty' ) } + + + { __( + 'Add widgets to start customizing your dashboard.' + ) } + + + ) } + + ); +} + +/** + * Renders an empty-state placeholder when the dashboard's `layout` has no + * widgets. Pair with `WidgetDashboard.Widgets` inside `WidgetDashboard` so + * the placeholder shows up in place of the grid until widgets are added. + * Without children, falls back to a built-in placeholder; pass children to + * override. + */ +export const NoWidgetsState = NoWidgetsStateImpl; diff --git a/routes/dashboard/widget-dashboard/components/widget-render/index.ts b/routes/dashboard/widget-dashboard/components/widget-render/index.ts new file mode 100644 index 00000000000000..4020f454ddebfa --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widget-render/index.ts @@ -0,0 +1 @@ +export { WidgetRender } from './widget-render'; diff --git a/routes/dashboard/widget-dashboard/components/widget-render/widget-render.module.css b/routes/dashboard/widget-dashboard/components/widget-render/widget-render.module.css new file mode 100644 index 00000000000000..eb14e333f3031e --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widget-render/widget-render.module.css @@ -0,0 +1,10 @@ +.loading, +.error { + height: 100%; + padding: var(--wpds-dimension-padding-md); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.error { + text-align: center; +} diff --git a/routes/dashboard/widget-dashboard/components/widget-render/widget-render.tsx b/routes/dashboard/widget-dashboard/components/widget-render/widget-render.tsx new file mode 100644 index 00000000000000..51f533660153a5 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widget-render/widget-render.tsx @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { Component, Suspense, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Stack } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import { useDashboardInternalContext } from '../../context/dashboard-context'; +import { getLazyWidgetComponent } from '../../utils/get-lazy-widget-component'; +import styles from './widget-render.module.css'; +import type { DashboardWidget, WidgetType } from '../../types'; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; +} + +class WidgetErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true }; + } + + render() { + if ( this.state.hasError ) { + return ( + +

{ __( 'This widget encountered an error.' ) }

+
+ ); + } + return this.props.children; + } +} + +function LoadingOverlay() { + return ( + + { __( 'Loading…' ) } + + ); +} + +interface WidgetRenderInternalProps { + widget: DashboardWidget< unknown >; + widgetType: WidgetType; +} + +function WidgetRenderImpl( { widget, widgetType }: WidgetRenderInternalProps ) { + const { layout, onLayoutChange, resolveWidgetModule } = + useDashboardInternalContext(); + + const WidgetComponent = getLazyWidgetComponent( + widgetType.renderModule, + resolveWidgetModule + ); + + const setAttributes = useCallback( + ( next: Partial< unknown > ) => { + onLayoutChange( + layout.map( ( w ) => + w.uuid === widget.uuid + ? { + ...w, + attributes: { + ...( w.attributes as object ), + ...( next as object ), + }, + } + : w + ) + ); + }, + [ widget.uuid, layout, onLayoutChange ] + ); + + return ( + + }> + { /* WidgetComponent is a cached `lazy()` keyed by renderModule, so its identity stays stable across renders. */ } + { /* eslint-disable-next-line react-hooks/static-components */ } + + + + ); +} + +/** + * Lazy-loads a widget's render module via the configured resolver and renders + * it with the minimal `WidgetRenderProps` contract: `attributes` plus + * `setAttributes`. Wraps the module in a `Suspense` boundary and an error + * boundary so neighbours stay mounted if one widget fails. + * + * Kept internal to the package. Surfaces that want bare widget rendering + * should compose `WidgetDashboard.Widget` instead. + */ +export const WidgetRender = WidgetRenderImpl; diff --git a/routes/dashboard/widget-dashboard/components/widget/index.ts b/routes/dashboard/widget-dashboard/components/widget/index.ts new file mode 100644 index 00000000000000..9f82fa43022279 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widget/index.ts @@ -0,0 +1 @@ +export { Widget } from './widget'; diff --git a/routes/dashboard/widget-dashboard/components/widget/widget.module.css b/routes/dashboard/widget-dashboard/components/widget/widget.module.css new file mode 100644 index 00000000000000..69a68e78df2712 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widget/widget.module.css @@ -0,0 +1,4 @@ +.widget { + height: 100%; + box-sizing: border-box; +} diff --git a/routes/dashboard/widget-dashboard/components/widget/widget.tsx b/routes/dashboard/widget-dashboard/components/widget/widget.tsx new file mode 100644 index 00000000000000..d8a8bad7040a75 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widget/widget.tsx @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { forwardRef, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useDashboardInternalContext } from '../../context/dashboard-context'; +import { WidgetContextProvider } from '../../context/widget-context'; +import { WidgetRender } from '../widget-render'; +import styles from './widget.module.css'; +import type { DashboardWidget } from '../../types'; + +export interface WidgetProps { + widget: DashboardWidget< unknown >; + index: number; +} + +/** + * Per-instance wrapper. Currently a minimal slot that provides widget + * identity via context and hosts `WidgetRender`. Chrome (header, remove, + * badges, error UI, loading overlay) is tracked separately and extends this + * compound without changing the public signature. + */ +export const Widget = forwardRef< HTMLDivElement, WidgetProps >( + function Widget( { widget, index }, ref ) { + const { widgetTypes, editMode } = useDashboardInternalContext(); + const widgetType = widgetTypes.find( ( t ) => t.name === widget.type ); + + const contextValue = useMemo( + () => ( { + uuid: widget.uuid, + name: widget.type, + index, + } ), + [ widget.uuid, widget.type, index ] + ); + + if ( ! widgetType ) { + return null; + } + + return ( + +
+ +
+
+ ); + } +); diff --git a/routes/dashboard/widget-dashboard/components/widgets/index.ts b/routes/dashboard/widget-dashboard/components/widgets/index.ts new file mode 100644 index 00000000000000..f843d8df169254 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widgets/index.ts @@ -0,0 +1 @@ +export { Widgets } from './widgets'; diff --git a/routes/dashboard/widget-dashboard/components/widgets/widgets.module.css b/routes/dashboard/widget-dashboard/components/widgets/widgets.module.css new file mode 100644 index 00000000000000..a4b60b4606c172 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widgets/widgets.module.css @@ -0,0 +1,3 @@ +.grid { + width: 100%; +} diff --git a/routes/dashboard/widget-dashboard/components/widgets/widgets.tsx b/routes/dashboard/widget-dashboard/components/widgets/widgets.tsx new file mode 100644 index 00000000000000..e2ea54ed4f2752 --- /dev/null +++ b/routes/dashboard/widget-dashboard/components/widgets/widgets.tsx @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { forwardRef, useCallback, useMemo } from '@wordpress/element'; +import { DashboardGrid } from '@wordpress/grid'; +import type { DashboardGridLayoutItem } from '@wordpress/grid'; + +/** + * Internal dependencies + */ +import { useDashboardInternalContext } from '../../context/dashboard-context'; +import { Widget } from '../widget'; +import styles from './widgets.module.css'; +import type { DashboardWidget, WidgetName } from '../../types'; + +function toGridLayout( widgets: DashboardWidget[] ): DashboardGridLayoutItem[] { + return widgets.map( ( w ) => ( { + key: w.uuid, + ...w.placement, + } ) ); +} + +function applyGridChange( + widgets: DashboardWidget[], + gridLayout: DashboardGridLayoutItem[] +): DashboardWidget[] { + return gridLayout.map( ( { key, ...placement } ) => { + const existing = widgets.find( ( w ) => w.uuid === key ); + if ( ! existing ) { + return { + uuid: key, + type: '' as WidgetName, + placement, + }; + } + return { + ...existing, + placement, + }; + } ); +} + +export interface WidgetsProps { + className?: string; +} + +/** + * Iterates `layout`, delegates each entry to `WidgetDashboard.Widget`, and + * feeds the resulting tree into `@wordpress/grid`. + */ +export const Widgets = forwardRef< HTMLDivElement, WidgetsProps >( + function Widgets( { className }, ref ) { + const { layout, onLayoutChange, editMode, gridSettings } = + useDashboardInternalContext(); + + const gridLayout = useMemo( () => toGridLayout( layout ), [ layout ] ); + + const handleLayoutChange = useCallback( + ( newGridLayout: DashboardGridLayoutItem[] ) => { + onLayoutChange( applyGridChange( layout, newGridLayout ) ); + }, + [ layout, onLayoutChange ] + ); + + const children = layout.map( ( widget, index ) => ( + + ) ); + + const sharedProps = { + layout: gridLayout, + spacing: gridSettings.spacing, + rowHeight: gridSettings.rowHeight, + editMode, + onChangeLayout: handleLayoutChange, + }; + + return ( +
+ { gridSettings.columns !== undefined ? ( + + { children } + + ) : ( + + { children } + + ) } +
+ ); + } +); diff --git a/routes/dashboard/widget-dashboard/context/dashboard-context.tsx b/routes/dashboard/widget-dashboard/context/dashboard-context.tsx new file mode 100644 index 00000000000000..f0d3d1e0836241 --- /dev/null +++ b/routes/dashboard/widget-dashboard/context/dashboard-context.tsx @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { createContext, useContext, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { + ResolveWidgetModule, + WidgetGridSettings, + DashboardWidget, + WidgetType, +} from '../types'; + +/* + * Defaults for the active grid model. Applied when the consumer omits + * `gridSettings` entirely; if `gridSettings` is provided, the consumer's + * shape passes through untouched and missing fields fall back to whatever + * defaults the grid model itself supplies. + */ +const DEFAULT_GRID: WidgetGridSettings = { + minColumnWidth: 350, + rowHeight: 200, + spacing: 4, +}; +const DEFAULT_RESOLVE_WIDGET_MODULE: ResolveWidgetModule = ( moduleId ) => + import( /* webpackIgnore: true */ moduleId ); + +/** + * Rich state distributed to every compound component inside `WidgetDashboard`. + * Internal — compounds reach the full state via `useDashboardInternalContext()`. + */ +interface InternalDashboardContextValue { + widgetTypes: WidgetType[]; + layout: DashboardWidget[]; + onLayoutChange: ( layout: DashboardWidget[] ) => void; + editMode: boolean; + onEditChange?: ( next: boolean ) => void; + resolveWidgetModule: ResolveWidgetModule; + gridSettings: WidgetGridSettings; +} + +const Context = createContext< InternalDashboardContextValue | null >( null ); + +/** + * Compound-internal hook — exposes the full provider state. Not part of the + * public API; lives in the same module so compound components can reach the + * state directly. + */ +export function useDashboardInternalContext(): InternalDashboardContextValue { + const ctx = useContext( Context ); + if ( ! ctx ) { + throw new Error( + 'Dashboard compound used outside a WidgetDashboard subtree.' + ); + } + return ctx; +} + +interface ProviderProps { + widgetTypes: WidgetType[]; + layout: DashboardWidget[]; + onLayoutChange: ( layout: DashboardWidget[] ) => void; + editMode?: boolean; + onEditChange?: ( next: boolean ) => void; + resolveWidgetModule?: ResolveWidgetModule; + gridSettings?: WidgetGridSettings; + children: ReactNode; +} + +export function WidgetDashboardProvider( { + widgetTypes, + layout, + onLayoutChange, + editMode = false, + onEditChange, + resolveWidgetModule = DEFAULT_RESOLVE_WIDGET_MODULE, + gridSettings = DEFAULT_GRID, + children, +}: ProviderProps ) { + const value = useMemo< InternalDashboardContextValue >( + () => ( { + widgetTypes, + layout, + onLayoutChange, + editMode, + onEditChange, + resolveWidgetModule, + gridSettings, + } ), + [ + widgetTypes, + layout, + onLayoutChange, + editMode, + onEditChange, + resolveWidgetModule, + gridSettings, + ] + ); + + return { children }; +} diff --git a/routes/dashboard/widget-dashboard/context/widget-context.tsx b/routes/dashboard/widget-dashboard/context/widget-context.tsx new file mode 100644 index 00000000000000..80ebaa183e5fdd --- /dev/null +++ b/routes/dashboard/widget-dashboard/context/widget-context.tsx @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { WidgetContextValue } from '../types'; + +const WidgetContext = createContext< WidgetContextValue | null >( null ); + +export function WidgetContextProvider( { + value, + children, +}: { + value: WidgetContextValue; + children: ReactNode; +} ) { + return ( + + { children } + + ); +} + +/** + * Returns the current widget's identity (`uuid`, `name`, `index`). Returns + * `null` when called outside a widget render subtree. + */ +export function useWidgetContext(): WidgetContextValue | null { + return useContext( WidgetContext ); +} diff --git a/routes/dashboard/widget-dashboard/index.ts b/routes/dashboard/widget-dashboard/index.ts new file mode 100644 index 00000000000000..67dda21ba97cbb --- /dev/null +++ b/routes/dashboard/widget-dashboard/index.ts @@ -0,0 +1,2 @@ +export { WidgetDashboard } from './widget-dashboard'; +export type { DashboardWidget, WidgetType } from './types'; diff --git a/routes/dashboard/widget-dashboard/stories/index.story.tsx b/routes/dashboard/widget-dashboard/stories/index.story.tsx new file mode 100644 index 00000000000000..0764c8fc84c849 --- /dev/null +++ b/routes/dashboard/widget-dashboard/stories/index.story.tsx @@ -0,0 +1,362 @@ +/** + * External dependencies + */ +import type { ComponentProps, ComponentType } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { brush, home } from '@wordpress/icons'; +import { EmptyState } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import { WidgetDashboard } from '../widget-dashboard'; +import type { + ResolveWidgetModule, + DashboardWidget, + WidgetRenderProps, + WidgetType, +} from '../types'; + +/* + * Mock widget modules + * + * Widgets are resolved at render time via `resolveWidgetModule`. In + * production this maps a script-module id to a React component; here we + * keep it in-memory and introduce a small delay so the `Suspense` + * fallback is observable. + */ + +type HelloAttrs = { greeting: string }; + +function HelloWidget( { + attributes, + setAttributes, +}: WidgetRenderProps< HelloAttrs > ) { + return ( +
+ Hello widget +

{ attributes.greeting }

+ + setAttributes?.( { greeting: event.target.value } ) + } + aria-label="Greeting" + style={ { padding: 4 } } + /> +
+ ); +} + +type CounterAttrs = { count: number }; + +function CounterWidget( { + attributes, + setAttributes, +}: WidgetRenderProps< CounterAttrs > ) { + return ( +
+ Counter widget +

{ attributes.count }

+ +
+ ); +} + +function StaticWidget() { + return ( +
+ Static content +
+ ); +} + +const MOCK_MODULES: Record< string, { default: ComponentType< any > } > = { + 'mock/hello': { default: HelloWidget }, + 'mock/counter': { default: CounterWidget }, + 'mock/static': { default: StaticWidget }, +}; + +const resolveWidgetModule: ResolveWidgetModule = ( moduleId ) => + new Promise( ( resolve, reject ) => { + setTimeout( () => { + const mod = MOCK_MODULES[ moduleId ]; + if ( ! mod ) { + reject( new Error( `Unknown mock module: ${ moduleId }` ) ); + return; + } + resolve( mod ); + }, 200 ); + } ); + +const widgetTypes: WidgetType[] = [ + { + apiVersion: 1, + name: 'mock/hello', + title: 'Hello', + renderModule: 'mock/hello', + example: { attributes: { greeting: 'Hi there' } }, + }, + { + apiVersion: 1, + name: 'mock/counter', + title: 'Counter', + renderModule: 'mock/counter', + example: { attributes: { count: 0 } }, + }, + { + apiVersion: 1, + name: 'mock/static', + title: 'Static', + renderModule: 'mock/static', + }, +]; + +const defaultLayout: DashboardWidget[] = [ + { + uuid: 'w1', + type: 'mock/hello', + attributes: { greeting: 'Good morning' }, + placement: { width: 2, height: 2 }, + }, + { + uuid: 'w2', + type: 'mock/counter', + attributes: { count: 3 }, + placement: { width: 2, height: 2 }, + }, + { + uuid: 'w3', + type: 'mock/static', + placement: { width: 'fill', height: 2 }, + }, + { + uuid: 'w4', + type: 'mock/static', + placement: { width: 'full', height: 1 }, + }, +]; + +function StatefulDashboard( props: ComponentProps< typeof WidgetDashboard > ) { + const [ layout, setLayout ] = useState( props.layout ); + + return ( +
+ { + setLayout( next ); + props.onLayoutChange?.( next ); + } } + /> +
+ ); +} + +const meta: Meta< typeof WidgetDashboard > = { + title: 'Dashboard/WidgetDashboard', + component: WidgetDashboard, + render: ( args ) => , + args: { + widgetTypes, + resolveWidgetModule, + editMode: false, + gridSettings: { columns: 6, spacing: 2, rowHeight: 120 }, + }, + argTypes: { + children: { control: false }, + layout: { control: false }, + widgetTypes: { control: false }, + resolveWidgetModule: { control: false }, + gridSettings: { control: false }, + onLayoutChange: { action: 'onLayoutChange' }, + onEditChange: { action: 'onEditChange' }, + editMode: { control: { type: 'boolean' } }, + }, + parameters: { + layout: 'fullscreen', + }, +}; +export default meta; + +type Story = StoryObj< typeof WidgetDashboard >; + +export const Default: Story = { + args: { + layout: defaultLayout, + }, +}; + +export const EditMode: Story = { + args: { + layout: defaultLayout, + editMode: true, + }, +}; + +export const NoWidgets: Story = { + args: { + layout: [], + }, +}; + +export const NoWidgetsCustom: Story = { + args: { + layout: [], + children: ( + <> + + + + + Make this dashboard yours + + + + Pass any children + { ' ' } + to NoWidgetsState to replace the + built-in placeholder. + + + + + + ), + }, +}; + +export const Responsive: Story = { + args: { + layout: defaultLayout, + gridSettings: { minColumnWidth: 220, spacing: 2, rowHeight: 120 }, + }, +}; + +/* + * Demonstrates that `` is just a container around its + * children. Consumers can interleave the compound parts (`NoWidgetsState`, + * `Widgets`) with any other markup — headers, sidebars, stats, footers — + * to compose richer surfaces without losing the engine's behaviour. + */ +export const Composition: Story = { + args: { + layout: defaultLayout, + editMode: true, + children: ( +
+
+
+

Workspace

+

+ Custom chrome can wrap the engine compounds. +

+
+ + { defaultLayout.length } widgets + +
+ + + + + + Your dashboard is empty + + + Add widgets to start customizing your dashboard. + + + + + + +
+ Drag widgets to reorder while edit mode is on. +
+
+ ), + }, +}; diff --git a/routes/dashboard/widget-dashboard/test/create-dashboard-widget.test.ts b/routes/dashboard/widget-dashboard/test/create-dashboard-widget.test.ts new file mode 100644 index 00000000000000..615c59fcf58c2e --- /dev/null +++ b/routes/dashboard/widget-dashboard/test/create-dashboard-widget.test.ts @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ +import { createDashboardWidget } from '../utils/create-dashboard-widget'; +import type { WidgetType } from '../types'; + +const baseType: WidgetType = { + apiVersion: 1, + name: 'core/example', + title: 'Example', + renderModule: 'https://example.test/widget.js', +}; + +describe( 'createDashboardWidget', () => { + it( 'stamps the type name and a unique uuid', () => { + const a = createDashboardWidget( baseType ); + const b = createDashboardWidget( baseType ); + + expect( a.type ).toBe( 'core/example' ); + expect( b.type ).toBe( 'core/example' ); + expect( a.uuid ).not.toBe( b.uuid ); + expect( a.uuid ).toMatch( /^[0-9a-f-]{36}$/ ); + } ); + + it( 'applies default placement values', () => { + const instance = createDashboardWidget( baseType ); + expect( instance.placement ).toEqual( { + width: 1, + height: 2, + order: 0, + } ); + } ); + + it( 'uses initialAttributes when provided', () => { + const instance = createDashboardWidget< { greeting: string } >( + baseType, + { greeting: 'hi' } + ); + expect( instance.attributes ).toEqual( { greeting: 'hi' } ); + } ); + + it( 'falls back to the type example attributes when no attributes are supplied', () => { + const typeWithExample: WidgetType = { + ...baseType, + example: { attributes: { greeting: 'default' } }, + }; + const instance = createDashboardWidget( typeWithExample ); + expect( instance.attributes ).toEqual( { greeting: 'default' } ); + } ); + + it( 'leaves attributes undefined when no example and no initial provided', () => { + const instance = createDashboardWidget( baseType ); + expect( instance.attributes ).toBeUndefined(); + } ); +} ); diff --git a/routes/dashboard/widget-dashboard/test/dashboard-context.test.tsx b/routes/dashboard/widget-dashboard/test/dashboard-context.test.tsx new file mode 100644 index 00000000000000..2c932d91373e81 --- /dev/null +++ b/routes/dashboard/widget-dashboard/test/dashboard-context.test.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { useWidgetContext } from '../context/widget-context'; + +function CaptureWidgetContext( { + onRender, +}: { + onRender: ( value: ReturnType< typeof useWidgetContext > ) => void; +} ) { + onRender( useWidgetContext() ); + return null; +} + +describe( 'useWidgetContext', () => { + it( 'returns null outside a widget render subtree', () => { + const handler = jest.fn(); + render( ); + expect( handler ).toHaveBeenCalledWith( null ); + } ); +} ); diff --git a/routes/dashboard/widget-dashboard/test/widget-dashboard.test.tsx b/routes/dashboard/widget-dashboard/test/widget-dashboard.test.tsx new file mode 100644 index 00000000000000..88f824a3ffb847 --- /dev/null +++ b/routes/dashboard/widget-dashboard/test/widget-dashboard.test.tsx @@ -0,0 +1,154 @@ +/** + * External dependencies + */ +import '@testing-library/jest-dom'; +import type { ComponentType } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { WidgetDashboard } from '../widget-dashboard'; +import type { + ResolveWidgetModule, + DashboardWidget, + WidgetRenderProps, + WidgetType, +} from '../types'; + +type Attrs = { greeting: string }; + +function TestWidget( { + attributes, + setAttributes, +}: WidgetRenderProps< Attrs > ) { + return ( +
+

{ attributes.greeting }

+ +
+ ); +} + +const widgetTypes: WidgetType[] = [ + { + apiVersion: 1, + name: 'test/greet', + title: 'Greet', + renderModule: 'test-greet-module', + }, +]; + +const resolveWidgetModule: ResolveWidgetModule = async ( id ) => { + if ( id === 'test-greet-module' ) { + return { + default: TestWidget as ComponentType< + WidgetRenderProps< unknown > + >, + }; + } + throw new Error( `Unknown module: ${ id }` ); +}; + +const initialLayout: DashboardWidget< Attrs >[] = [ + { + uuid: 'w1', + type: 'test/greet', + attributes: { greeting: 'hello' }, + placement: { width: 2, height: 2 }, + }, +]; + +function Harness( { + onLayoutChange, +}: { + onLayoutChange?: ( layout: DashboardWidget[] ) => void; +} ) { + const [ layout, setLayout ] = useState( initialLayout ); + + return ( + { + setLayout( next as DashboardWidget< Attrs >[] ); + onLayoutChange?.( next ); + } } + widgetTypes={ widgetTypes } + resolveWidgetModule={ resolveWidgetModule } + /> + ); +} + +describe( 'WidgetDashboard', () => { + it( 'resolves the widget module and renders attributes', async () => { + render( ); + + expect( await screen.findByTestId( 'greeting' ) ).toHaveTextContent( + 'hello' + ); + } ); + + it( 'threads setAttributes into onLayoutChange with merged attributes', async () => { + const onChange = jest.fn(); + render( ); + + const button = await screen.findByRole( 'button', { + name: 'Update', + } ); + await userEvent.click( button ); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + const [ updated ] = onChange.mock.calls[ 0 ]; + expect( updated ).toHaveLength( 1 ); + expect( updated[ 0 ] ).toMatchObject( { + uuid: 'w1', + type: 'test/greet', + attributes: { greeting: 'updated' }, + } ); + } ); + + it( 'renders nothing for an unknown widget type (no crash)', () => { + render( + {} } + widgetTypes={ widgetTypes } + resolveWidgetModule={ resolveWidgetModule } + /> + ); + expect( screen.queryByTestId( 'greeting' ) ).not.toBeInTheDocument(); + } ); + + it( 'renders the NoWidgetsState compound when layout is empty', () => { + render( + {} } + widgetTypes={ widgetTypes } + resolveWidgetModule={ resolveWidgetModule } + > + +

Nothing here yet

+
+ +
+ ); + expect( screen.getByText( 'Nothing here yet' ) ).toBeInTheDocument(); + } ); +} ); diff --git a/routes/dashboard/widget-dashboard/types.ts b/routes/dashboard/widget-dashboard/types.ts new file mode 100644 index 00000000000000..535a906cc72f4d --- /dev/null +++ b/routes/dashboard/widget-dashboard/types.ts @@ -0,0 +1,316 @@ +/** + * Widget type definitions. + */ + +/** + * External dependencies + */ +import type { ComponentType, ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import type { Field } from '@wordpress/dataviews'; +import type { DashboardGridLayoutItem } from '@wordpress/grid'; + +/* + * MIGRATION: `WidgetName`, `WidgetTypeMetadata`, and `WidgetType` below + * are also defined in `@wordpress/widget-types` (currently on its own + * branch). When that package lands in trunk, replace the three + * declarations with: + * + * export type { + * WidgetName, + * WidgetTypeMetadata, + * WidgetType, + * } from '@wordpress/widget-types'; + * + * The shapes are kept identical on purpose so the swap is mechanical — + * any change to the fields here must land in lockstep on the + * `@wordpress/widget-types` package to keep the cutover trivial. + */ + +/** + * Widget type identifier, structured as `/`. + * Both segments are lowercase, kebab-case; the full character pattern is + * enforced by the `widget.json` schema at authoring time. + */ +export type WidgetName = `${ string }/${ string }`; + +/** + * Literal contents of a widget's `widget.json` metadata file. + * + * Captures the *authoring* shape only — module entry points and style + * assets are discovered by convention from the widget directory + * (`render.*`, `widget.*`, `render.scss`), not declared here. + * + * Consumed by tooling (IDE autocomplete, validation, the build pipeline). + * The dashboard engine consumes the richer `WidgetType` below, which + * extends this shape with runtime-only fields produced by the build + * manifest. + */ +export interface WidgetTypeMetadata { + /** + * Version of the Widget API used by the widget. + */ + apiVersion: number; + + /** + * Stable type identifier. See `WidgetName` for the shape. + */ + name: WidgetName; + + /** + * Display title; shown in the inserter. + */ + title: string; + + /** + * Short description shown in the widget inspector. + */ + description?: string; + + /** + * Dashicon slug used as the visual identifier. + */ + icon?: string; + + /** + * Grouping category. Core provides `dashboard`; plugins and themes may + * register custom categories. + */ + category?: string; + + /** + * Search aliases used to surface the widget from the inserter. + */ + keywords?: string[]; + + /** + * Widget version — used for asset cache invalidation. + */ + version?: string; + + /** + * Gettext text domain for translations. + */ + textdomain?: string; + + /** + * Experiment gate — boolean `true`, or a specific experiment name. + */ + __experimental?: string | boolean; + + /** + * Declarative attribute schema. Surfaces render forms straight from + * this list via `DataForm`, with no per-widget form wiring. `any` is + * used here because the array is heterogeneous — each widget narrows + * `Item` to its own attribute type at the point of registration. + */ + attributes?: Field< any >[]; + + /** + * Structured example data for the Inspector Help Panel preview, and + * the default attributes applied by `createDashboardWidget` when no + * initial attributes are supplied. + */ + example?: { + attributes?: Record< string, unknown >; + }; +} + +/** + * Runtime widget type consumed by the dashboard engine. + * + * Extends `WidgetTypeMetadata` (the authoring shape of `widget.json`) with + * runtime-only fields produced by the build pipeline — notably + * `renderModule`, which maps each widget to its discovered script-module + * entry point. + * + * Surfaces consume `WidgetType[]` via the `widgetTypes` prop; the + * dashboard never reads the widget-types store directly. + */ +export interface WidgetType extends WidgetTypeMetadata { + /** + * Script-module identifier resolved to a React component at render + * time by `ResolveWidgetModule`. Produced by the build pipeline from + * the conventional `render.*` / `widget.*` entry points; not declared + * in `widget.json`. + */ + renderModule: string; +} + +export type GridTilePlacement = Omit< DashboardGridLayoutItem, 'key' >; + +/** + * A widget placed on the dashboard. + * + * A `WidgetType` describes the blueprint. A `DashboardWidget` is a concrete + * placement of that type on a specific dashboard: its unique id, the type it + * references, user-configured attributes, and its `placement` in the grid. + * + * The `Placement` generic defaults to the packed grid's item shape + * (`DashboardGridLayoutItem` minus `key`, which the engine derives from + * `uuid`). A different grid model — masonry, stack, absolute — would use a + * different `Placement` shape; the widget identity stays unchanged. + */ +export interface DashboardWidget< + Item = unknown, + Placement = GridTilePlacement, +> { + /** + * Unique instance identifier. + */ + uuid: string; + + /** + * Widget type name — must match a `WidgetType.name` in `widgetTypes`. + */ + type: WidgetName; + + /** + * User-configured attributes for this instance. + */ + attributes?: Item; + + /** + * Grid-model-specific placement (column/row spans, ordering, etc.). + */ + placement?: Placement; +} + +/** + * Props passed to every widget render component. + */ +export interface WidgetRenderProps< Item = unknown > { + /** + * Widget attributes configured by the user. + */ + attributes: Item; + + /** + * Update the attributes of this instance. Fires `onLayoutChange` on the + * dashboard with the updated layout. + */ + setAttributes?: ( next: Partial< Item > ) => void; +} + +/** + * Identity of a widget within the rendering tree. Returned by + * `useWidgetContext()`; `null` when called outside a widget render subtree. + */ +export interface WidgetContextValue { + /** + * Widget instance id. + */ + uuid: string; + + /** + * Widget type name. + */ + name: WidgetName; + + /** + * Index of the widget in the `layout` array. + */ + index: number; +} + +/** + * Widget render module shape returned by the module resolver. + */ +export interface WidgetModule { + default: ComponentType< WidgetRenderProps< unknown > >; +} + +/** + * Resolver hook: maps a `WidgetType.renderModule` id to a React component. + * Defaults to a dynamic `import()`; override for tests, Storybook, or to load + * from a non-URL source. + */ +export type ResolveWidgetModule = ( + moduleId: string +) => Promise< WidgetModule >; + +/** + * Grid-model configuration. Today maps to `@wordpress/grid`'s settings. + * When alternative grid models (masonry, stack, ...) ship, this type + * becomes a discriminated union keyed by the chosen model and per-model + * settings are inferred from the model's own props. + * + * `columns` and `minColumnWidth` are mutually exclusive at runtime — set + * either one or the other depending on whether you want a fixed or + * responsive grid. The dashboard does not enforce the xor at the type + * level so `react-docgen-typescript` (Storybook) can serialize the prop + * cleanly; the underlying grid component handles the conflict. + */ +export interface WidgetGridSettings { + /** + * Fixed column count. Mutually exclusive with `minColumnWidth`. + */ + columns?: number; + + /** + * Responsive minimum column width in pixels. Mutually exclusive with + * `columns`. + */ + minColumnWidth?: number; + + /** + * Row height in pixels, or `'auto'`. + */ + rowHeight?: number | 'auto'; + + /** + * Grid gap multiplier (multiplied by 4px). + */ + spacing?: number; +} + +/** + * Props for `WidgetDashboard`. + * + * The consumer owns layout state; every mutation fires `onLayoutChange` + * with the fully updated array. + */ +export interface WidgetDashboardProps { + /** + * Widget instances to render. Consumer owns this state. + */ + layout: DashboardWidget[]; + + /** + * Called on every layout mutation (reorder, resize, add, remove). + */ + onLayoutChange: ( layout: DashboardWidget[] ) => void; + + /** + * Widget types available for rendering. The dashboard never queries a + * store directly — consumers scope and filter via this prop. + */ + widgetTypes: WidgetType[]; + + /** + * Whether the dashboard is in edit mode (enables drag/resize). + */ + editMode?: boolean; + + /** + * Called when edit mode toggles via `WidgetDashboard.Actions`. + */ + onEditChange?: ( next: boolean ) => void; + + /** + * Overrides the default `import()` resolution of + * `WidgetType.renderModule`. Useful for tests, Storybook, or future + * remote-URL loading. + */ + resolveWidgetModule?: ResolveWidgetModule; + + /** + * Grid model configuration. See `WidgetGridSettings` for the shape. + */ + gridSettings?: WidgetGridSettings; + + children?: ReactNode; +} diff --git a/routes/dashboard/widget-dashboard/utils/create-dashboard-widget/create-dashboard-widget.ts b/routes/dashboard/widget-dashboard/utils/create-dashboard-widget/create-dashboard-widget.ts new file mode 100644 index 00000000000000..16e4b16fc40c90 --- /dev/null +++ b/routes/dashboard/widget-dashboard/utils/create-dashboard-widget/create-dashboard-widget.ts @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import type { + DashboardWidget, + WidgetType, + GridTilePlacement, +} from '../../types'; + +const DEFAULT_PLACEMENT: GridTilePlacement = { + width: 1, + height: 2, + order: 0, +}; + +/** + * Create a new dashboard widget from a widget type. + * + * Generates a unique id and applies default placement. If no initial + * attributes are provided, falls back to the type's `example.attributes` + * (matching the `widget.json` schema). + * @param widgetType + * @param initialAttributes + */ +export function createDashboardWidget< T >( + widgetType: WidgetType, + initialAttributes?: T +): DashboardWidget< T > { + return { + uuid: crypto.randomUUID(), + type: widgetType.name, + attributes: + initialAttributes ?? ( widgetType.example?.attributes as T ), + placement: DEFAULT_PLACEMENT, + }; +} diff --git a/routes/dashboard/widget-dashboard/utils/create-dashboard-widget/index.ts b/routes/dashboard/widget-dashboard/utils/create-dashboard-widget/index.ts new file mode 100644 index 00000000000000..1d82fa39165a2c --- /dev/null +++ b/routes/dashboard/widget-dashboard/utils/create-dashboard-widget/index.ts @@ -0,0 +1 @@ +export { createDashboardWidget } from './create-dashboard-widget'; diff --git a/routes/dashboard/widget-dashboard/utils/get-lazy-widget-component/get-lazy-widget-component.ts b/routes/dashboard/widget-dashboard/utils/get-lazy-widget-component/get-lazy-widget-component.ts new file mode 100644 index 00000000000000..1a2f8ebe382e9f --- /dev/null +++ b/routes/dashboard/widget-dashboard/utils/get-lazy-widget-component/get-lazy-widget-component.ts @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import type { ComponentType } from 'react'; + +/** + * WordPress dependencies + */ +import { lazy } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { + ResolveWidgetModule, + WidgetModule, + WidgetRenderProps, +} from '../../types'; + +type LazyWidgetComponent = ComponentType< WidgetRenderProps< unknown > >; + +function isValidWidgetModule( module: unknown ): module is WidgetModule { + return ( + typeof module === 'object' && + module !== null && + 'default' in module && + typeof ( module as { default: unknown } ).default === 'function' + ); +} + +/* + * Module-level cache keyed by `renderModule`. The lazy component must keep a + * stable identity across renders: rebuilding it inline (e.g. via `useMemo`) + * resets the Suspense boundary and the resolved module each time the surface + * re-renders. + */ +const componentCache = new Map< string, LazyWidgetComponent >(); + +/** + * Resolve a widget render module to a `lazy()` React component, cached by + * `renderModule` id. The first call for a given id builds the lazy wrapper + * around the resolver; subsequent calls return the same instance so that + * surfaces and Suspense boundaries can rely on a stable component identity. + * @param renderModule + * @param resolveWidgetModule + */ +export function getLazyWidgetComponent( + renderModule: string, + resolveWidgetModule: ResolveWidgetModule +): LazyWidgetComponent { + const cached = componentCache.get( renderModule ); + if ( cached ) { + return cached; + } + + const lazyComponent = lazy< LazyWidgetComponent >( async () => { + const module: unknown = await resolveWidgetModule( renderModule ); + if ( ! isValidWidgetModule( module ) ) { + throw new Error( `Invalid widget module: ${ renderModule }` ); + } + + return module; + } ); + + componentCache.set( renderModule, lazyComponent ); + return lazyComponent; +} diff --git a/routes/dashboard/widget-dashboard/utils/get-lazy-widget-component/index.ts b/routes/dashboard/widget-dashboard/utils/get-lazy-widget-component/index.ts new file mode 100644 index 00000000000000..06691d032ea6f8 --- /dev/null +++ b/routes/dashboard/widget-dashboard/utils/get-lazy-widget-component/index.ts @@ -0,0 +1 @@ +export { getLazyWidgetComponent } from './get-lazy-widget-component'; diff --git a/routes/dashboard/widget-dashboard/utils/index.ts b/routes/dashboard/widget-dashboard/utils/index.ts new file mode 100644 index 00000000000000..dfedc38f777e15 --- /dev/null +++ b/routes/dashboard/widget-dashboard/utils/index.ts @@ -0,0 +1,2 @@ +export { createDashboardWidget } from './create-dashboard-widget'; +export { getLazyWidgetComponent } from './get-lazy-widget-component'; diff --git a/routes/dashboard/widget-dashboard/widget-dashboard.tsx b/routes/dashboard/widget-dashboard/widget-dashboard.tsx new file mode 100644 index 00000000000000..f950fd2509b2f8 --- /dev/null +++ b/routes/dashboard/widget-dashboard/widget-dashboard.tsx @@ -0,0 +1,68 @@ +/** + * Internal dependencies + */ +import { WidgetDashboardProvider } from './context/dashboard-context'; +import { Widget } from './components/widget'; +import { Widgets } from './components/widgets'; +import type { WidgetDashboardProps } from './types'; +import { NoWidgetsState } from './components/no-widgets-state'; + +/** + * Stateless rendering engine for widget dashboards. + * + * The consumer owns `layout` and `editMode` state; every mutation fires + * `onLayoutChange` with the fully updated array. The engine never queries a + * widget-types store — types flow in via the `widgetTypes` prop. + * + * ```tsx + * import { WidgetDashboard } from '@wordpress/dashboard'; + * + * function MyDashboard() { + * const [ layout, setLayout ] = useState( defaultLayout ); + * return ( + * + * + *

No widgets yet.

+ *
+ * + *
+ * ); + * } + * ``` + */ +export const WidgetDashboard = Object.assign( + function WidgetDashboard( { + layout, + onLayoutChange, + widgetTypes, + editMode, + onEditChange, + resolveWidgetModule, + gridSettings, + children, + }: WidgetDashboardProps ) { + return ( + + { children ?? ( + <> + + + + ) } + + ); + }, + { Widgets, Widget, NoWidgetsState } +); diff --git a/storybook/main.ts b/storybook/main.ts index 529df7a8dffc98..f50442c2d9e74f 100644 --- a/storybook/main.ts +++ b/storybook/main.ts @@ -33,6 +33,7 @@ const stories = [ '../packages/theme/src/**/stories/*.mdx', '../packages/theme/src/**/stories/*.story.@(tsx|mdx)', '../packages/grid/src/**/stories/*.story.@(ts|tsx)', + '../routes/dashboard/**/stories/*.story.@(ts|tsx)', '../packages/ui/src/**/stories/*.mdx', '../packages/ui/src/**/stories/*.story.@(ts|tsx)', '../packages/admin-ui/src/**/stories/*.story.@(ts|tsx)',