Skip to content

Commit ae940c4

Browse files
retrofoxsimison
andauthored
Dashboard: add WidgetDashboard.Actions compound (#78019)
* add WidgetDashboard.Actions compound reads editMode/onEditChange from the internal context and renders the edit-mode toggle. returns null when onEditChange is not wired so surfaces opting out can keep Actions in their tree unconditionally. * mount WidgetDashboard.Actions in dashboard route * split Actions handlers per button * Update routes/dashboard/stage.tsx Co-authored-by: Mikael Korpela <mikael@ihminen.org> * Update routes/dashboard/widget-dashboard/components/actions/actions.tsx Co-authored-by: Mikael Korpela <mikael@ihminen.org> * lead Actions README example with <Page> --------- Co-authored-by: Mikael Korpela <mikael@ihminen.org>
1 parent c70fc19 commit ae940c4

6 files changed

Lines changed: 259 additions & 9 deletions

File tree

‎routes/dashboard/stage.tsx‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,23 @@ function Dashboard() {
2828

2929
const widgetTypes = useWidgetTypes();
3030

31+
const [ editMode, setEditMode ] = useState( false );
32+
3133
return (
32-
<Page title={ __( 'Dashboard' ) } headingLevel={ 1 }>
33-
<WidgetDashboard
34-
layout={ layout }
35-
onLayoutChange={ setLayout }
36-
widgetTypes={ widgetTypes }
37-
/>
38-
</Page>
34+
<WidgetDashboard
35+
layout={ layout }
36+
onLayoutChange={ setLayout }
37+
widgetTypes={ widgetTypes }
38+
editMode={ editMode }
39+
onEditChange={ setEditMode }
40+
>
41+
<Page
42+
title={ __( 'Dashboard' ) }
43+
actions={ <WidgetDashboard.Actions /> }
44+
>
45+
<WidgetDashboard.Widgets />
46+
</Page>
47+
</WidgetDashboard>
3948
);
4049
}
4150

‎routes/dashboard/widget-dashboard/README.md‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ When `true`, the grid enables drag and resize. Defaults to `false`.
5858

5959
#### `onEditChange`: `( next: boolean ) => void`
6060

61-
Optional. Called when edit mode toggles via a future `WidgetDashboard.Actions` compound.
61+
Optional. Called when edit mode toggles via `WidgetDashboard.Actions` (or any consumer-built toggle). When omitted, `WidgetDashboard.Actions` renders nothing.
6262

6363
#### `resolveWidgetModule`: `( moduleId: string ) => Promise< { default: ComponentType } >`
6464

@@ -86,6 +86,33 @@ Per-instance wrapper. Provides widget identity to the render tree via context an
8686

8787
Renders its children only when `layout` is empty. Pair it with `<WidgetDashboard.Widgets />` so the empty state shows up in place of the grid until widgets are added.
8888

89+
#### `<WidgetDashboard.Actions />`
90+
91+
Edit-mode toggle: a "Customize" button while `editMode` is off and a "Done" button while it is on. Clicking either fires `onEditChange` with the toggled value. Returns `null` when the dashboard is mounted without `onEditChange`, so surfaces that don't expose edit mode can keep `Actions` in their tree unconditionally.
92+
93+
`<Page>` from `@wordpress/admin-ui` exposes an `actions` slot used across admin surfaces (DataViews, WidgetDashboard, …). Plug `Actions` straight into it:
94+
95+
```tsx
96+
import { Page } from '@wordpress/admin-ui';
97+
98+
<WidgetDashboard
99+
layout={ layout }
100+
onLayoutChange={ setLayout }
101+
widgetTypes={ widgetTypes }
102+
editMode={ editMode }
103+
onEditChange={ setEditMode }
104+
>
105+
<Page
106+
title={ __( 'My Dashboard' ) }
107+
actions={ <WidgetDashboard.Actions /> }
108+
>
109+
<WidgetDashboard.Widgets />
110+
</Page>
111+
</WidgetDashboard>
112+
```
113+
114+
`<Page>` is optional. The compound renders inside any container, so a bare `<header>` or custom chrome works just as well.
115+
89116
## Authoring widgets
90117

91118
Widget render modules receive only what they need to render and edit:
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { useCallback } from '@wordpress/element';
5+
import { __ } from '@wordpress/i18n';
6+
// eslint-disable-next-line @wordpress/use-recommended-components
7+
import { Button, Stack } from '@wordpress/ui';
8+
9+
/**
10+
* Internal dependencies
11+
*/
12+
import { useDashboardInternalContext } from '../../context/dashboard-context';
13+
14+
/**
15+
* Renders the dashboard's edit-mode toggle. Shows a "Customize" button while
16+
* `editMode` is off and a "Done" button while it is on; clicking either fires
17+
* `onEditChange` with the toggled value.
18+
*
19+
* Returns `null` when the dashboard is mounted without `onEditChange` so
20+
* surfaces that don't expose edit mode can keep `Actions` in their tree
21+
* unconditionally.
22+
*
23+
* @return {React.ReactNode} - The Actions component.
24+
*/
25+
export function Actions(): React.ReactNode {
26+
const { editMode, onEditChange } = useDashboardInternalContext();
27+
28+
const handleEditMode = useCallback( () => {
29+
onEditChange?.( ! editMode );
30+
}, [ editMode, onEditChange ] );
31+
32+
const handleInsertWidget = useCallback( () => {
33+
// eslint-disable-next-line no-console
34+
console.log( 'insert widget' ); // TODO: Implement widget insertion
35+
}, [] );
36+
37+
const handleCancel = useCallback( () => {
38+
// eslint-disable-next-line no-console
39+
console.log( 'cancel' ); // TODO: Implement cancel\
40+
onEditChange?.( false );
41+
}, [ onEditChange ] );
42+
43+
const handleDone = useCallback( () => {
44+
// eslint-disable-next-line no-console
45+
console.log( 'done' ); // TODO: Implement done
46+
onEditChange?.( false );
47+
}, [ onEditChange ] );
48+
49+
if ( ! onEditChange ) {
50+
return null;
51+
}
52+
53+
return (
54+
<Stack direction="row" gap="sm">
55+
{ editMode ? (
56+
<>
57+
<Button
58+
variant="minimal"
59+
tone="brand"
60+
size="compact"
61+
onClick={ handleInsertWidget }
62+
>
63+
{ __( 'Add widgets' ) }
64+
</Button>
65+
<Button
66+
variant="minimal"
67+
tone="brand"
68+
size="compact"
69+
onClick={ handleCancel }
70+
>
71+
{ __( 'Cancel' ) }
72+
</Button>
73+
<Button
74+
variant="solid"
75+
tone="brand"
76+
size="compact"
77+
onClick={ handleDone }
78+
>
79+
{ __( 'Done' ) }
80+
</Button>
81+
</>
82+
) : (
83+
<Button
84+
variant="outline"
85+
tone="brand"
86+
size="compact"
87+
onClick={ handleEditMode }
88+
>
89+
{ __( 'Customize' ) }
90+
</Button>
91+
) }
92+
</Stack>
93+
);
94+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Actions } from './actions';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import '@testing-library/jest-dom';
5+
import { render, screen } from '@testing-library/react';
6+
import userEvent from '@testing-library/user-event';
7+
8+
/**
9+
* WordPress dependencies
10+
*/
11+
import { useState } from '@wordpress/element';
12+
13+
/**
14+
* Internal dependencies
15+
*/
16+
import { WidgetDashboard } from '../widget-dashboard';
17+
import type { DashboardWidget, WidgetType } from '../types';
18+
19+
const widgetTypes: WidgetType[] = [];
20+
const layout: DashboardWidget[] = [];
21+
22+
interface HarnessProps {
23+
initialEditMode?: boolean;
24+
onEditChange?: ( next: boolean ) => void;
25+
}
26+
27+
function Harness( { initialEditMode = false, onEditChange }: HarnessProps ) {
28+
const [ editMode, setEditMode ] = useState( initialEditMode );
29+
30+
return (
31+
<WidgetDashboard
32+
layout={ layout }
33+
onLayoutChange={ () => {} }
34+
widgetTypes={ widgetTypes }
35+
editMode={ editMode }
36+
onEditChange={ ( next ) => {
37+
setEditMode( next );
38+
onEditChange?.( next );
39+
} }
40+
>
41+
<WidgetDashboard.Actions />
42+
</WidgetDashboard>
43+
);
44+
}
45+
46+
describe( 'WidgetDashboard.Actions', () => {
47+
it( 'renders the Customize button when editMode is false', () => {
48+
render( <Harness /> );
49+
50+
expect(
51+
screen.getByRole( 'button', { name: 'Customize' } )
52+
).toBeInTheDocument();
53+
expect(
54+
screen.queryByRole( 'button', { name: 'Done' } )
55+
).not.toBeInTheDocument();
56+
} );
57+
58+
it( 'renders the Done button when editMode is true', () => {
59+
render( <Harness initialEditMode /> );
60+
61+
expect(
62+
screen.getByRole( 'button', { name: 'Done' } )
63+
).toBeInTheDocument();
64+
expect(
65+
screen.queryByRole( 'button', { name: 'Customize' } )
66+
).not.toBeInTheDocument();
67+
} );
68+
69+
it( 'fires onEditChange with the toggled value on click', async () => {
70+
const onEditChange = jest.fn();
71+
render( <Harness onEditChange={ onEditChange } /> );
72+
73+
await userEvent.click(
74+
screen.getByRole( 'button', { name: 'Customize' } )
75+
);
76+
expect( onEditChange ).toHaveBeenLastCalledWith( true );
77+
78+
await userEvent.click( screen.getByRole( 'button', { name: 'Done' } ) );
79+
expect( onEditChange ).toHaveBeenLastCalledWith( false );
80+
expect( onEditChange ).toHaveBeenCalledTimes( 2 );
81+
82+
// TODO: drop once Done has its own committed behavior; today it logs.
83+
// eslint-disable-next-line jest/no-standalone-expect
84+
expect( console ).toHaveLogged( 'done' );
85+
} );
86+
87+
it( 'renders nothing when onEditChange is not provided', () => {
88+
render(
89+
<WidgetDashboard
90+
layout={ layout }
91+
onLayoutChange={ () => {} }
92+
widgetTypes={ widgetTypes }
93+
>
94+
<WidgetDashboard.Actions />
95+
</WidgetDashboard>
96+
);
97+
98+
expect(
99+
screen.queryByRole( 'button', { name: 'Customize' } )
100+
).not.toBeInTheDocument();
101+
expect(
102+
screen.queryByRole( 'button', { name: 'Done' } )
103+
).not.toBeInTheDocument();
104+
} );
105+
106+
it( 'throws when used outside a WidgetDashboard subtree', () => {
107+
const spy = jest
108+
.spyOn( console, 'error' )
109+
.mockImplementation( () => {} );
110+
111+
expect( () => render( <WidgetDashboard.Actions /> ) ).toThrow(
112+
/Dashboard compound used outside a WidgetDashboard subtree/
113+
);
114+
115+
spy.mockRestore();
116+
} );
117+
} );

‎routes/dashboard/widget-dashboard/widget-dashboard.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Internal dependencies
33
*/
44
import { WidgetDashboardProvider } from './context/dashboard-context';
5+
import { Actions } from './components/actions';
56
import { Widget } from './components/widget';
67
import { Widgets } from './components/widgets';
78
import type { WidgetDashboardProps } from './types';
@@ -58,11 +59,12 @@ export const WidgetDashboard = Object.assign(
5859
{ children ?? (
5960
<>
6061
<NoWidgetsState />
62+
<Actions />
6163
<Widgets />
6264
</>
6365
) }
6466
</WidgetDashboardProvider>
6567
);
6668
},
67-
{ Widgets, Widget, NoWidgetsState }
69+
{ Actions, Widgets, Widget, NoWidgetsState }
6870
);

0 commit comments

Comments
 (0)