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
73 changes: 56 additions & 17 deletions docs/custom-components/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,23 @@ Define widgets in your Payload config using the `admin.dashboard.widgets` proper
import { buildConfig } from 'payload'

export default buildConfig({
// ...
admin: {
dashboard: {
widgets: [
{
slug: 'user-stats',
ComponentPath: './components/UserStats.tsx#default',
minWidth: 'medium',
maxWidth: 'full',
},
{
slug: 'revenue-chart',
ComponentPath: './components/RevenueChart.tsx#default',
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',
},
],
},
Expand All @@ -55,6 +58,7 @@ export default buildConfig({
| ------------------ | ------------- | -------------------------------------------------------------------- |
| `slug` \* | `string` | Unique identifier for the widget |
| `ComponentPath` \* | `string` | Path to the widget component (supports `#` syntax for named exports) |
| `fields` | `Field[]` | Optional widget-specific form fields shown in the edit drawer |
| `minWidth` | `WidgetWidth` | Minimum width the widget can be resized to (default: `'x-small'`) |
| `maxWidth` | `WidgetWidth` | Maximum width the widget can be resized to (default: `'full'`) |

Expand Down Expand Up @@ -103,6 +107,14 @@ export default buildConfig({

return [
{ widgetSlug: 'collections', width: 'full' },
{
widgetSlug: 'sales-summary',
data: {
timeframe: 'monthly',
title: 'Revenue Overview',
},
width: isAdmin ? 'medium' : 'small',
},
{ widgetSlug: 'user-stats', width: isAdmin ? 'medium' : 'full' },
{ widgetSlug: 'revenue-chart', width: 'full' },
]
Expand All @@ -117,12 +129,38 @@ export default buildConfig({

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

If your widget has `fields`, you can type `widgetData` with generated widget types:

```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>
)
}
```

#### WidgetInstance Type

| Property | Type | Description |
| --------------- | ------------- | ----------------------------------------------- |
| `widgetSlug` \* | `string` | Slug of the widget to display |
| `width` | `WidgetWidth` | Initial width of the widget (default: minWidth) |
| Property | Type | Description |
| --------------- | ------------- | ---------------------------------------------------- |
| `widgetSlug` \* | `string` | Slug of the widget to display |
| `data` | `object` | Optional widget-specific data passed to `widgetData` |
| `width` | `WidgetWidth` | Initial width of the widget (default: minWidth) |

`width` is constrained by each widget's `minWidth` and `maxWidth` when types are generated.

<Banner type="success">
**Tip:** Users can customize their dashboard layout, which is saved to their
Expand All @@ -145,9 +183,10 @@ Users can customize their dashboard by:
1. Clicking the dashboard dropdown in the breadcrumb
2. Selecting "Edit Dashboard"
3. Adding widgets via the "Add +" button
4. Resizing widgets using the width dropdown on each widget
5. Reordering widgets via drag-and-drop
6. Deleting widgets using the delete button
7. Saving changes or canceling to revert
4. Editing widget data (for widgets with `fields`) via the edit button
5. Resizing widgets using the width dropdown on each widget (if multiple widths are allowed)
6. Reordering widgets via drag-and-drop
7. Deleting widgets using the delete button
8. Saving changes or canceling to revert

Users can also reset their dashboard to the default layout using the "Reset Layout" option.
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use client'

import type { ClientWidget, FormState } from 'payload'

import {
Drawer,
Form,
FormSubmit,
OperationProvider,
RenderFields,
ShimmerEffect,
useLocale,
useModal,
useServerFunctions,
useTranslation,
} from '@payloadcms/ui'
import { abortAndIgnore } from '@payloadcms/ui/shared'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { v4 as uuid } from 'uuid'

import { extractLocaleData, mergeLocaleData } from './utils/localeUtils.js'

type WidgetConfigDrawerProps = {
drawerSlug: string
onSave: (data: Record<string, unknown>) => void
widget: ClientWidget
widgetData?: Record<string, unknown>
}

const EMPTY_WIDGET_PREFERENCES = {
fields: {},
}

export function WidgetConfigDrawer({
drawerSlug,
onSave,
widget,
widgetData,
}: WidgetConfigDrawerProps) {
const { closeModal, modalState } = useModal()
const { getFormState } = useServerFunctions()
const { t } = useTranslation()
const locale = useLocale()
const localeCode = locale?.code ?? 'en'
const onChangeAbortControllerRef = useRef<AbortController>(null)

const [initialState, setInitialState] = useState<false | FormState | undefined>(false)

const isOpen = Boolean(modalState?.[drawerSlug]?.isOpen)
const formUUID = useMemo(() => uuid(), [])
Comment thread
GermanJablo marked this conversation as resolved.
const widgetLabel = useMemo(
() => (typeof widget.label === 'string' ? widget.label : widget.slug),
[widget.label, widget.slug],
)
const fields = useMemo(() => widget.fields ?? [], [widget.fields])

useEffect(() => {
if (!isOpen || fields.length === 0) {
setInitialState(false)
return
}

const controller = new AbortController()

const loadInitialState = async () => {
const localeFilteredData = extractLocaleData(widgetData ?? {}, localeCode, fields)

const { state } = await getFormState({
data: localeFilteredData,
docPermissions: {
fields: true,
},
docPreferences: EMPTY_WIDGET_PREFERENCES,
locale: localeCode,
operation: 'update',
renderAllFields: true,
schemaPath: widget.slug,
signal: controller.signal,
widgetSlug: widget.slug,
})

if (state) {
setInitialState(state)
}
}

void loadInitialState()

return () => {
abortAndIgnore(controller)
}
}, [fields, getFormState, isOpen, localeCode, widget.slug, widgetData])

const onChange = useCallback(
async ({ formState: prevFormState }: { formState: FormState }) => {
abortAndIgnore(onChangeAbortControllerRef.current)

const controller = new AbortController()
onChangeAbortControllerRef.current = controller

const { state } = await getFormState({
docPermissions: {
fields: true,
},
docPreferences: EMPTY_WIDGET_PREFERENCES,
formState: prevFormState,
operation: 'update',
schemaPath: widget.slug,
signal: controller.signal,
widgetSlug: widget.slug,
})

if (!state) {
return prevFormState
}

return state
},
[getFormState, widget.slug],
)

useEffect(() => {
return () => {
abortAndIgnore(onChangeAbortControllerRef.current)
}
}, [])

return (
<Drawer slug={drawerSlug} title={`${t('general:edit')} ${widgetLabel}`}>
{initialState === false ? (
<ShimmerEffect height="250px" />
) : (
<OperationProvider operation="update">
<Form
fields={fields}
initialState={initialState}
onChange={[onChange]}
onSubmit={(_, data) => {
onSave(mergeLocaleData(widgetData ?? {}, data, localeCode, fields))
closeModal(drawerSlug)
}}
uuid={formUUID}
>
<RenderFields
fields={fields}
forceRender
parentIndexPath=""
parentPath=""
parentSchemaPath={widget.slug}
permissions={true}
readOnly={false}
/>
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
</OperationProvider>
)}
</Drawer>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client'

import type { Data } from 'payload'

import { EditIcon, useConfig, useModal, useTranslation } from '@payloadcms/ui'
import React, { useId } from 'react'

import { WidgetConfigDrawer } from './WidgetConfigDrawer.js'

const getWidgetSlugFromID = (widgetID: string): string =>
widgetID.slice(0, widgetID.lastIndexOf('-'))

export function WidgetEditControl({
onSave,
widgetData,
widgetID,
}: {
onSave: (data: Data) => void
widgetData?: Record<string, unknown>
widgetID: string
}) {
const { t } = useTranslation()
const { openModal } = useModal()
const { widgets: configWidgets = [] } = useConfig().config.admin.dashboard ?? {}

const widgetSlug = getWidgetSlugFromID(widgetID)
const widgetConfig = configWidgets.find((widget) => widget.slug === widgetSlug)
const hasEditableFields = Boolean(widgetConfig?.fields?.length)

const drawerID = useId()
const drawerSlug = `widget-editor-${drawerID}`

if (!hasEditableFields) {
return null
}

return (
<>
<button
className="widget-wrapper__edit-btn"
onClick={() => {
openModal(drawerSlug)
}}
type="button"
>
<span className="sr-only">
{t('general:edit')} {widgetID}
</span>
<EditIcon />
</button>
<WidgetConfigDrawer
drawerSlug={drawerSlug}
onSave={onSave}
widget={widgetConfig}
widgetData={widgetData}
/>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ import { DashboardStepNav } from './DashboardStepNav.js'
import { useDashboardLayout } from './useDashboardLayout.js'
import { closestInXAxis } from './utils/collisionDetection.js'
import { useDashboardSensors } from './utils/sensors.js'
import { WidgetEditControl } from './WidgetEditControl.js'

export type WidgetItem = {
data?: Record<string, unknown>
id: string
maxWidth: WidgetWidth
minWidth: WidgetWidth
Expand Down Expand Up @@ -76,6 +78,7 @@ export function ModularDashboardClient({
resizeWidget,
saveLayout,
setIsEditing,
updateWidgetData,
} = useDashboardLayout(initialLayout)

const [activeDragId, setActiveDragId] = useState<null | string>(null)
Expand Down Expand Up @@ -166,6 +169,13 @@ export function ModularDashboardClient({
className="widget-wrapper__controls"
onPointerDown={(e) => e.stopPropagation()}
>
<WidgetEditControl
onSave={(data) => {
updateWidgetData(widget.item.id, data)
}}
widgetData={widget.item.data}
widgetID={widget.item.id}
/>
<WidgetWidthDropdown
currentWidth={widget.item.width}
maxWidth={widget.item.maxWidth}
Expand Down Expand Up @@ -259,12 +269,15 @@ function WidgetWidthDropdown({

const isDisabled = validOptions.length <= 1

if (isDisabled) {
return null
}

return (
<Popup
button={
<button
className="widget-wrapper__size-btn"
disabled={isDisabled}
onPointerDown={(e) => e.stopPropagation()}
type="button"
>
Expand All @@ -273,7 +286,6 @@ function WidgetWidthDropdown({
</button>
}
buttonType="custom"
disabled={isDisabled}
render={({ close }) => (
<PopupList.ButtonGroup>
{validOptions.map((option) => {
Expand Down
Loading