Skip to content

Commit f111624

Browse files
authored
feat: modular dashboards - widgets (#13683)
[RFC Here](#11862). You can test the feature by running `pnpm dev dashboard` on this branch. <details> <summary>Old (obsolete) example</summary> See the [comment below](#13683 (comment)) explaining the change in approach we took https://github.com/user-attachments/assets/96157f83-c5d7-4350-9f31-c014daedb2a8 </details> ### New demo https://github.com/user-attachments/assets/6c08d8d6-c989-4845-b56f-6d3fbd30b1af ## Future Work The following improvements are planned but will be added in the future: - fields: You'll be able to define `fields` that a widget receives, which will serve as props in the component. Why might this be useful? Imagine a chart widget that can have a weekly, daily, or yearly view. Or a "count" widget that shows how many documents there are in a collection (the collection could be a field). - A11y (EDITED): Okay, I finally went the extra mile here, and you can reorder and resize it with the keyboard. The screen reader works, although there's room for improvement. - Dashboard presets: we're planning to add the ability to create and share dashboard presets, similar to how [query presets](https://payloadcms.com/docs/query-presets/overview) work today. For example, you could build dashboards that adjust based on a variable, such as a "daily, weekly, or monthly" interval. You could also create dashboards tailored to different focus areas, like "marketing, sales, or product."
1 parent e3c512a commit f111624

112 files changed

Lines changed: 4934 additions & 370 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/main.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ jobs:
233233
- auth
234234
- auth-basic
235235
- bulk-edit
236+
- dashboard
236237
- joins
237238
- field-error-states
238239
- fields-relationship
@@ -387,6 +388,7 @@ jobs:
387388
- auth
388389
- auth-basic
389390
- bulk-edit
391+
- dashboard
390392
- joins
391393
- field-error-states
392394
- fields-relationship

docs/custom-components/custom-views.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ const config = buildConfig({
4646
})
4747
```
4848

49+
<Banner type="success">
50+
**Note:** The dashboard is a special case, where in addition to replacing the
51+
default view, [you can add widgets modularly](../custom-components/dashboard).
52+
</Banner>
53+
4954
For more granular control, pass a configuration object instead. Payload exposes the following properties for each view:
5055

5156
| Property | Description |
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
---
2+
title: Dashboard Widgets
3+
label: Dashboard
4+
order: 45
5+
desc: Create custom dashboard widgets to display data, analytics, or any other content in the Payload Admin Panel.
6+
keywords: dashboard, widgets, custom components, admin, React
7+
---
8+
9+
<Banner type="warning">
10+
This new Modular Dashboard is an experimental feature and may change in future
11+
releases. Use at your own risk.
12+
</Banner>
13+
14+
The Dashboard is the first page users see when they log into the Payload Admin Panel. By default, it displays cards with the collections and globals. You can customize the dashboard by adding **widgets** - modular components that can display data, analytics, or any other content.
15+
16+
One of the coolest things about widgets is that each plugin can define its own. Some examples:
17+
18+
- Analytics
19+
- Error Reporting
20+
- Number of documents that meet a certain filter
21+
- Jobs recently executed
22+
23+
### Defining Widgets
24+
25+
Define widgets in your Payload config using the `admin.dashboard.widgets` property:
26+
27+
```ts
28+
import { buildConfig } from 'payload'
29+
30+
export default buildConfig({
31+
// ...
32+
admin: {
33+
dashboard: {
34+
widgets: [
35+
{
36+
slug: 'user-stats',
37+
ComponentPath: './components/UserStats.tsx#default',
38+
minWidth: 'medium',
39+
maxWidth: 'full',
40+
},
41+
{
42+
slug: 'revenue-chart',
43+
ComponentPath: './components/RevenueChart.tsx#default',
44+
minWidth: 'small',
45+
},
46+
],
47+
},
48+
},
49+
})
50+
```
51+
52+
### Widget Configuration
53+
54+
| Property | Type | Description |
55+
| ------------------ | ------------- | -------------------------------------------------------------------- |
56+
| `slug` \* | `string` | Unique identifier for the widget |
57+
| `ComponentPath` \* | `string` | Path to the widget component (supports `#` syntax for named exports) |
58+
| `minWidth` | `WidgetWidth` | Minimum width the widget can be resized to (default: `'x-small'`) |
59+
| `maxWidth` | `WidgetWidth` | Maximum width the widget can be resized to (default: `'full'`) |
60+
61+
**WidgetWidth Values:** `'x-small'` \| `'small'` \| `'medium'` \| `'large'` \| `'x-large'` \| `'full'`
62+
63+
### Creating a Widget Component
64+
65+
Widgets are React Server Components that receive `WidgetServerProps`:
66+
67+
```tsx
68+
import type { WidgetServerProps } from 'payload'
69+
70+
export default async function UserStatsWidget({ req }: WidgetServerProps) {
71+
const { payload } = req
72+
73+
// Fetch data server-side
74+
const userCount = await payload.count({ collection: 'users' })
75+
76+
return (
77+
<div className="card">
78+
<h3>Total Users</h3>
79+
<p style={{ fontSize: '32px', fontWeight: 'bold' }}>
80+
{userCount.totalDocs}
81+
</p>
82+
</div>
83+
)
84+
}
85+
```
86+
87+
For visual consistency with the Payload UI, we recommend:
88+
89+
1. Using the `card` class for your root element, unless you don't want it to have a background color.
90+
2. Using our theme variables for backgrounds and text colors. For example, use `var(--theme-elevation-0)` for backgrounds and `var(--theme-text)` for text colors.
91+
92+
### Default Layout
93+
94+
Control the initial dashboard layout with the `defaultLayout` property:
95+
96+
```ts
97+
export default buildConfig({
98+
admin: {
99+
dashboard: {
100+
defaultLayout: ({ req }) => {
101+
// Customize layout based on user role or other factors
102+
const isAdmin = req.user?.roles?.includes('admin')
103+
104+
return [
105+
{ widgetSlug: 'collections', width: 'full' },
106+
{ widgetSlug: 'user-stats', width: isAdmin ? 'medium' : 'full' },
107+
{ widgetSlug: 'revenue-chart', width: 'full' },
108+
]
109+
},
110+
widgets: [
111+
// ... widget definitions
112+
],
113+
},
114+
},
115+
})
116+
```
117+
118+
The `defaultLayout` function receives the request object and should return an array of `WidgetInstance` objects.
119+
120+
#### WidgetInstance Type
121+
122+
| Property | Type | Description |
123+
| --------------- | ------------- | ----------------------------------------------- |
124+
| `widgetSlug` \* | `string` | Slug of the widget to display |
125+
| `width` | `WidgetWidth` | Initial width of the widget (default: minWidth) |
126+
127+
<Banner type="success">
128+
**Tip:** Users can customize their dashboard layout, which is saved to their
129+
preferences. The `defaultLayout` is only used for first-time visitors or after
130+
a layout reset.
131+
</Banner>
132+
133+
### Built-in Widgets
134+
135+
Payload includes a built-in `collections` widget that displays collection and global cards.
136+
137+
If you don't define a `defaultLayout`, the collections widget will be automatically included in your dashboard.
138+
139+
### User Customization
140+
141+
{/* TODO: maybe a good GIF here? */}
142+
143+
Users can customize their dashboard by:
144+
145+
1. Clicking the dashboard dropdown in the breadcrumb
146+
2. Selecting "Edit Dashboard"
147+
3. Adding widgets via the "Add +" button
148+
4. Resizing widgets using the width dropdown on each widget
149+
5. Reordering widgets via drag-and-drop
150+
6. Deleting widgets using the delete button
151+
7. Saving changes or canceling to revert
152+
153+
Users can also reset their dashboard to the default layout using the "Reset Layout" option.

docs/custom-components/root-components.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ export default function MyBeforeDashboardComponent() {
127127
}
128128
```
129129

130+
<Banner type="success">
131+
**Note:** You can also set [Dashboard Widgets](../custom-components/dashboard)
132+
in the `admin.dashboard` property, or replace the entire [Dashboard
133+
View](../custom-components/dashboard) with your own.
134+
</Banner>
135+
130136
### afterDashboard
131137

132138
Similar to `beforeDashboard`, the `afterDashboard` property allows you to inject Custom Components into the built-in Dashboard, _after_ the default dashboard contents.
@@ -156,6 +162,12 @@ export default function MyAfterDashboardComponent() {
156162
}
157163
```
158164

165+
<Banner type="success">
166+
**Note:** You can also set [Dashboard Widgets](../custom-components/dashboard)
167+
in the `admin.dashboard` property, or replace the entire [Dashboard
168+
View](../custom-components/dashboard) with your own.
169+
</Banner>
170+
159171
### beforeLogin
160172

161173
The `beforeLogin` property allows you to inject Custom Components into the built-in Login view, _before_ the default login form.

packages/next/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@
109109
},
110110
"dependencies": {
111111
"@dnd-kit/core": "6.0.8",
112+
"@dnd-kit/modifiers": "9.0.0",
113+
"@dnd-kit/sortable": "7.0.2",
112114
"@payloadcms/graphql": "workspace:*",
113115
"@payloadcms/translations": "workspace:*",
114116
"@payloadcms/ui": "workspace:*",

packages/next/src/utilities/getVisibleEntities.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

packages/next/src/utilities/handleServerFunctions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { buildTableStateHandler } from '@payloadcms/ui/utilities/buildTableState
66
import { getFolderResultsComponentAndDataHandler } from '@payloadcms/ui/utilities/getFolderResultsComponentAndData'
77
import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublishHandler'
88

9+
import { getDefaultLayoutHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.js'
10+
import { renderWidgetHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.js'
911
import { renderDocumentHandler } from '../views/Document/handleServerFunction.js'
1012
import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js'
1113
import { renderListHandler } from '../views/List/handleServerFunction.js'
@@ -15,11 +17,13 @@ import { slugifyHandler } from './slugify.js'
1517
const baseServerFunctions: Record<string, ServerFunction<any, any>> = {
1618
'copy-data-from-locale': copyDataFromLocaleHandler,
1719
'form-state': buildFormStateHandler,
20+
'get-default-layout': getDefaultLayoutHandler,
1821
'get-folder-results-component-and-data': getFolderResultsComponentAndDataHandler,
1922
'render-document': renderDocumentHandler,
2023
'render-document-slots': renderDocumentSlotsHandler,
2124
'render-field': _internal_renderFieldHandler,
2225
'render-list': renderListHandler,
26+
'render-widget': renderWidgetHandler,
2327
'schedule-publish': schedulePublishHandler,
2428
slugify: slugifyHandler,
2529
'table-state': buildTableStateHandler,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
'use client'
2+
import type { ClientWidget } from 'payload'
3+
4+
import {
5+
Button,
6+
DrawerToggler,
7+
ItemsDrawer,
8+
type ReactSelectOption as Option,
9+
ReactSelect,
10+
useStepNav,
11+
useTranslation,
12+
} from '@payloadcms/ui'
13+
import { useEffect, useId } from 'react'
14+
15+
export function DashboardStepNav({
16+
addWidget,
17+
cancel,
18+
isEditing,
19+
resetLayout,
20+
saveLayout,
21+
setIsEditing,
22+
widgets,
23+
}: {
24+
addWidget: (slug: string) => void
25+
cancel: () => void
26+
isEditing: boolean
27+
resetLayout: () => Promise<void>
28+
saveLayout: () => Promise<void>
29+
setIsEditing: (isEditing: boolean) => void
30+
widgets: ClientWidget[]
31+
}) {
32+
const { t } = useTranslation()
33+
const { setStepNav } = useStepNav()
34+
const uuid = useId()
35+
const drawerSlug = `widgets-drawer-${uuid}`
36+
37+
useEffect(() => {
38+
setStepNav([
39+
{
40+
label: (
41+
<DashboardBreadcrumbDropdown
42+
isEditing={isEditing}
43+
onCancel={cancel}
44+
onEditClick={() => setIsEditing(true)}
45+
onResetLayout={resetLayout}
46+
onSaveChanges={saveLayout}
47+
widgetsDrawerSlug={drawerSlug}
48+
/>
49+
),
50+
},
51+
])
52+
}, [isEditing, drawerSlug, cancel, resetLayout, saveLayout, setIsEditing, setStepNav])
53+
54+
return (
55+
<>
56+
{isEditing && (
57+
<ItemsDrawer
58+
drawerSlug={drawerSlug}
59+
items={widgets}
60+
onItemClick={(widget) => addWidget(widget.slug)}
61+
searchPlaceholder={t('dashboard:searchWidgets')}
62+
title={t('dashboard:addWidget')}
63+
/>
64+
)}
65+
</>
66+
)
67+
}
68+
69+
export function DashboardBreadcrumbDropdown(props: {
70+
isEditing: boolean
71+
onCancel: () => void
72+
onEditClick: () => void
73+
onResetLayout: () => void
74+
onSaveChanges: () => void
75+
widgetsDrawerSlug: string
76+
}) {
77+
const { isEditing, onCancel, onEditClick, onResetLayout, onSaveChanges, widgetsDrawerSlug } =
78+
props
79+
if (isEditing) {
80+
return (
81+
<div className="dashboard-breadcrumb-dropdown__editing">
82+
<span>Editing Dashboard</span>
83+
<div className="dashboard-breadcrumb-dropdown__actions">
84+
<DrawerToggler className="drawer-toggler--unstyled" slug={widgetsDrawerSlug}>
85+
<Button buttonStyle="pill" el="span" size="small">
86+
Add +
87+
</Button>
88+
</DrawerToggler>
89+
<Button buttonStyle="pill" onClick={onSaveChanges} size="small">
90+
Save Changes
91+
</Button>
92+
<Button buttonStyle="pill" onClick={onCancel} size="small">
93+
Cancel
94+
</Button>
95+
</div>
96+
</div>
97+
)
98+
}
99+
100+
const options = [
101+
{ label: 'Edit Dashboard', value: 'edit' },
102+
{ label: 'Reset Layout', value: 'reset' },
103+
]
104+
105+
const handleChange = (selectedOption: Option | Option[]) => {
106+
// Since isMulti is false, we expect a single Option
107+
const option = Array.isArray(selectedOption) ? selectedOption[0] : selectedOption
108+
109+
if (option?.value === 'edit') {
110+
onEditClick()
111+
} else if (option?.value === 'reset') {
112+
onResetLayout()
113+
}
114+
}
115+
116+
return (
117+
<ReactSelect
118+
className="dashboard-breadcrumb-select"
119+
isClearable={false}
120+
isSearchable={false}
121+
menuIsOpen={undefined} // Let ReactSelect handle open/close
122+
onChange={handleChange}
123+
options={options}
124+
placeholder="Dashboard"
125+
value={{ label: 'Dashboard', value: 'dashboard' }}
126+
/>
127+
)
128+
}

0 commit comments

Comments
 (0)