Skip to content

Commit 0a123b5

Browse files
authored
feat(next, ui): widget fields (#15700)
## Summary - Adds `fields` support to dashboard widgets, analogous to how Blocks work — widgets can now declare configurable fields - Widget data is editable from a new drawer UI (edit icon) when in dashboard editing mode - Full type generation: `WidgetInstance<T>` is now generic with typed `data` and `width` based on the widget's field schema and min/max width constraints - `WidgetServerProps` is generic so widget components receive typed `widgetData` ## Usage ```ts import { buildConfig } from 'payload' export default buildConfig({ admin: { dashboard: { widgets: [ { slug: 'sales-summary', ComponentPath: './components/SalesSummary.tsx#default', fields: [ { name: 'title', type: 'text' }, { name: 'timeframe', type: 'select', options: ['daily', 'weekly', 'monthly', 'yearly'], }, { name: 'showTrend', type: 'checkbox' }, ], minWidth: 'small', maxWidth: 'medium', }, ], }, }, }) ``` ```tsx import type { WidgetServerProps } from 'payload' import type { SalesSummaryWidget } from '../payload-types' export default async function SalesSummaryWidgetComponent({ widgetData, }: WidgetServerProps<SalesSummaryWidget>) { const title = widgetData?.title ?? 'Sales Summary' const timeframe = widgetData?.timeframe ?? 'monthly' return ( <div className="card"> <h3> {title} ({timeframe}) </h3> </div> ) } ``` ## Demo https://github.com/user-attachments/assets/b24d3635-8635-4d5f-84af-a6dcf801aa4f
1 parent c1892eb commit 0a123b5

35 files changed

Lines changed: 1355 additions & 154 deletions

docs/custom-components/dashboard.mdx

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,23 @@ Define widgets in your Payload config using the `admin.dashboard.widgets` proper
2828
import { buildConfig } from 'payload'
2929

3030
export default buildConfig({
31-
// ...
3231
admin: {
3332
dashboard: {
3433
widgets: [
3534
{
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',
35+
slug: 'sales-summary',
36+
ComponentPath: './components/SalesSummary.tsx#default',
37+
fields: [
38+
{ name: 'title', type: 'text' },
39+
{
40+
name: 'timeframe',
41+
type: 'select',
42+
options: ['daily', 'weekly', 'monthly', 'yearly'],
43+
},
44+
{ name: 'showTrend', type: 'checkbox' },
45+
],
4446
minWidth: 'small',
47+
maxWidth: 'medium',
4548
},
4649
],
4750
},
@@ -55,6 +58,7 @@ export default buildConfig({
5558
| ------------------ | ------------- | -------------------------------------------------------------------- |
5659
| `slug` \* | `string` | Unique identifier for the widget |
5760
| `ComponentPath` \* | `string` | Path to the widget component (supports `#` syntax for named exports) |
61+
| `fields` | `Field[]` | Optional widget-specific form fields shown in the edit drawer |
5862
| `minWidth` | `WidgetWidth` | Minimum width the widget can be resized to (default: `'x-small'`) |
5963
| `maxWidth` | `WidgetWidth` | Maximum width the widget can be resized to (default: `'full'`) |
6064

@@ -103,6 +107,14 @@ export default buildConfig({
103107

104108
return [
105109
{ widgetSlug: 'collections', width: 'full' },
110+
{
111+
widgetSlug: 'sales-summary',
112+
data: {
113+
timeframe: 'monthly',
114+
title: 'Revenue Overview',
115+
},
116+
width: isAdmin ? 'medium' : 'small',
117+
},
106118
{ widgetSlug: 'user-stats', width: isAdmin ? 'medium' : 'full' },
107119
{ widgetSlug: 'revenue-chart', width: 'full' },
108120
]
@@ -117,12 +129,38 @@ export default buildConfig({
117129

118130
The `defaultLayout` function receives the request object and should return an array of `WidgetInstance` objects.
119131

132+
If your widget has `fields`, you can type `widgetData` with generated widget types:
133+
134+
```tsx
135+
import type { WidgetServerProps } from 'payload'
136+
137+
import type { SalesSummaryWidget } from '../payload-types'
138+
139+
export default async function SalesSummaryWidgetComponent({
140+
widgetData,
141+
}: WidgetServerProps<SalesSummaryWidget>) {
142+
const title = widgetData?.title ?? 'Sales Summary'
143+
const timeframe = widgetData?.timeframe ?? 'monthly'
144+
145+
return (
146+
<div className="card">
147+
<h3>
148+
{title} ({timeframe})
149+
</h3>
150+
</div>
151+
)
152+
}
153+
```
154+
120155
#### WidgetInstance Type
121156

122-
| Property | Type | Description |
123-
| --------------- | ------------- | ----------------------------------------------- |
124-
| `widgetSlug` \* | `string` | Slug of the widget to display |
125-
| `width` | `WidgetWidth` | Initial width of the widget (default: minWidth) |
157+
| Property | Type | Description |
158+
| --------------- | ------------- | ---------------------------------------------------- |
159+
| `widgetSlug` \* | `string` | Slug of the widget to display |
160+
| `data` | `object` | Optional widget-specific data passed to `widgetData` |
161+
| `width` | `WidgetWidth` | Initial width of the widget (default: minWidth) |
162+
163+
`width` is constrained by each widget's `minWidth` and `maxWidth` when types are generated.
126164

127165
<Banner type="success">
128166
**Tip:** Users can customize their dashboard layout, which is saved to their
@@ -145,9 +183,10 @@ Users can customize their dashboard by:
145183
1. Clicking the dashboard dropdown in the breadcrumb
146184
2. Selecting "Edit Dashboard"
147185
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
186+
4. Editing widget data (for widgets with `fields`) via the edit button
187+
5. Resizing widgets using the width dropdown on each widget (if multiple widths are allowed)
188+
6. Reordering widgets via drag-and-drop
189+
7. Deleting widgets using the delete button
190+
8. Saving changes or canceling to revert
152191

153192
Users can also reset their dashboard to the default layout using the "Reset Layout" option.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
'use client'
2+
3+
import type { ClientWidget, FormState } from 'payload'
4+
5+
import {
6+
Drawer,
7+
Form,
8+
FormSubmit,
9+
OperationProvider,
10+
RenderFields,
11+
ShimmerEffect,
12+
useLocale,
13+
useModal,
14+
useServerFunctions,
15+
useTranslation,
16+
} from '@payloadcms/ui'
17+
import { abortAndIgnore } from '@payloadcms/ui/shared'
18+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
19+
import { v4 as uuid } from 'uuid'
20+
21+
import { extractLocaleData, mergeLocaleData } from './utils/localeUtils.js'
22+
23+
type WidgetConfigDrawerProps = {
24+
drawerSlug: string
25+
onSave: (data: Record<string, unknown>) => void
26+
widget: ClientWidget
27+
widgetData?: Record<string, unknown>
28+
}
29+
30+
const EMPTY_WIDGET_PREFERENCES = {
31+
fields: {},
32+
}
33+
34+
export function WidgetConfigDrawer({
35+
drawerSlug,
36+
onSave,
37+
widget,
38+
widgetData,
39+
}: WidgetConfigDrawerProps) {
40+
const { closeModal, modalState } = useModal()
41+
const { getFormState } = useServerFunctions()
42+
const { t } = useTranslation()
43+
const locale = useLocale()
44+
const localeCode = locale?.code ?? 'en'
45+
const onChangeAbortControllerRef = useRef<AbortController>(null)
46+
47+
const [initialState, setInitialState] = useState<false | FormState | undefined>(false)
48+
49+
const isOpen = Boolean(modalState?.[drawerSlug]?.isOpen)
50+
const formUUID = useMemo(() => uuid(), [])
51+
const widgetLabel = useMemo(
52+
() => (typeof widget.label === 'string' ? widget.label : widget.slug),
53+
[widget.label, widget.slug],
54+
)
55+
const fields = useMemo(() => widget.fields ?? [], [widget.fields])
56+
57+
useEffect(() => {
58+
if (!isOpen || fields.length === 0) {
59+
setInitialState(false)
60+
return
61+
}
62+
63+
const controller = new AbortController()
64+
65+
const loadInitialState = async () => {
66+
const localeFilteredData = extractLocaleData(widgetData ?? {}, localeCode, fields)
67+
68+
const { state } = await getFormState({
69+
data: localeFilteredData,
70+
docPermissions: {
71+
fields: true,
72+
},
73+
docPreferences: EMPTY_WIDGET_PREFERENCES,
74+
locale: localeCode,
75+
operation: 'update',
76+
renderAllFields: true,
77+
schemaPath: widget.slug,
78+
signal: controller.signal,
79+
widgetSlug: widget.slug,
80+
})
81+
82+
if (state) {
83+
setInitialState(state)
84+
}
85+
}
86+
87+
void loadInitialState()
88+
89+
return () => {
90+
abortAndIgnore(controller)
91+
}
92+
}, [fields, getFormState, isOpen, localeCode, widget.slug, widgetData])
93+
94+
const onChange = useCallback(
95+
async ({ formState: prevFormState }: { formState: FormState }) => {
96+
abortAndIgnore(onChangeAbortControllerRef.current)
97+
98+
const controller = new AbortController()
99+
onChangeAbortControllerRef.current = controller
100+
101+
const { state } = await getFormState({
102+
docPermissions: {
103+
fields: true,
104+
},
105+
docPreferences: EMPTY_WIDGET_PREFERENCES,
106+
formState: prevFormState,
107+
operation: 'update',
108+
schemaPath: widget.slug,
109+
signal: controller.signal,
110+
widgetSlug: widget.slug,
111+
})
112+
113+
if (!state) {
114+
return prevFormState
115+
}
116+
117+
return state
118+
},
119+
[getFormState, widget.slug],
120+
)
121+
122+
useEffect(() => {
123+
return () => {
124+
abortAndIgnore(onChangeAbortControllerRef.current)
125+
}
126+
}, [])
127+
128+
return (
129+
<Drawer slug={drawerSlug} title={`${t('general:edit')} ${widgetLabel}`}>
130+
{initialState === false ? (
131+
<ShimmerEffect height="250px" />
132+
) : (
133+
<OperationProvider operation="update">
134+
<Form
135+
fields={fields}
136+
initialState={initialState}
137+
onChange={[onChange]}
138+
onSubmit={(_, data) => {
139+
onSave(mergeLocaleData(widgetData ?? {}, data, localeCode, fields))
140+
closeModal(drawerSlug)
141+
}}
142+
uuid={formUUID}
143+
>
144+
<RenderFields
145+
fields={fields}
146+
forceRender
147+
parentIndexPath=""
148+
parentPath=""
149+
parentSchemaPath={widget.slug}
150+
permissions={true}
151+
readOnly={false}
152+
/>
153+
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
154+
</Form>
155+
</OperationProvider>
156+
)}
157+
</Drawer>
158+
)
159+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
3+
import type { Data } from 'payload'
4+
5+
import { EditIcon, useConfig, useModal, useTranslation } from '@payloadcms/ui'
6+
import React, { useId } from 'react'
7+
8+
import { WidgetConfigDrawer } from './WidgetConfigDrawer.js'
9+
10+
const getWidgetSlugFromID = (widgetID: string): string =>
11+
widgetID.slice(0, widgetID.lastIndexOf('-'))
12+
13+
export function WidgetEditControl({
14+
onSave,
15+
widgetData,
16+
widgetID,
17+
}: {
18+
onSave: (data: Data) => void
19+
widgetData?: Record<string, unknown>
20+
widgetID: string
21+
}) {
22+
const { t } = useTranslation()
23+
const { openModal } = useModal()
24+
const { widgets: configWidgets = [] } = useConfig().config.admin.dashboard ?? {}
25+
26+
const widgetSlug = getWidgetSlugFromID(widgetID)
27+
const widgetConfig = configWidgets.find((widget) => widget.slug === widgetSlug)
28+
const hasEditableFields = Boolean(widgetConfig?.fields?.length)
29+
30+
const drawerID = useId()
31+
const drawerSlug = `widget-editor-${drawerID}`
32+
33+
if (!hasEditableFields) {
34+
return null
35+
}
36+
37+
return (
38+
<>
39+
<button
40+
className="widget-wrapper__edit-btn"
41+
onClick={() => {
42+
openModal(drawerSlug)
43+
}}
44+
type="button"
45+
>
46+
<span className="sr-only">
47+
{t('general:edit')} {widgetID}
48+
</span>
49+
<EditIcon />
50+
</button>
51+
<WidgetConfigDrawer
52+
drawerSlug={drawerSlug}
53+
onSave={onSave}
54+
widget={widgetConfig}
55+
widgetData={widgetData}
56+
/>
57+
</>
58+
)
59+
}

packages/next/src/views/Dashboard/Default/ModularDashboard/index.client.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ import { DashboardStepNav } from './DashboardStepNav.js'
2828
import { useDashboardLayout } from './useDashboardLayout.js'
2929
import { closestInXAxis } from './utils/collisionDetection.js'
3030
import { useDashboardSensors } from './utils/sensors.js'
31+
import { WidgetEditControl } from './WidgetEditControl.js'
3132

3233
export type WidgetItem = {
34+
data?: Record<string, unknown>
3335
id: string
3436
maxWidth: WidgetWidth
3537
minWidth: WidgetWidth
@@ -76,6 +78,7 @@ export function ModularDashboardClient({
7678
resizeWidget,
7779
saveLayout,
7880
setIsEditing,
81+
updateWidgetData,
7982
} = useDashboardLayout(initialLayout)
8083

8184
const [activeDragId, setActiveDragId] = useState<null | string>(null)
@@ -166,6 +169,13 @@ export function ModularDashboardClient({
166169
className="widget-wrapper__controls"
167170
onPointerDown={(e) => e.stopPropagation()}
168171
>
172+
<WidgetEditControl
173+
onSave={(data) => {
174+
updateWidgetData(widget.item.id, data)
175+
}}
176+
widgetData={widget.item.data}
177+
widgetID={widget.item.id}
178+
/>
169179
<WidgetWidthDropdown
170180
currentWidth={widget.item.width}
171181
maxWidth={widget.item.maxWidth}
@@ -259,12 +269,15 @@ function WidgetWidthDropdown({
259269

260270
const isDisabled = validOptions.length <= 1
261271

272+
if (isDisabled) {
273+
return null
274+
}
275+
262276
return (
263277
<Popup
264278
button={
265279
<button
266280
className="widget-wrapper__size-btn"
267-
disabled={isDisabled}
268281
onPointerDown={(e) => e.stopPropagation()}
269282
type="button"
270283
>
@@ -273,7 +286,6 @@ function WidgetWidthDropdown({
273286
</button>
274287
}
275288
buttonType="custom"
276-
disabled={isDisabled}
277289
render={({ close }) => (
278290
<PopupList.ButtonGroup>
279291
{validOptions.map((option) => {

0 commit comments

Comments
 (0)