feat(ui): expose refresh method to list drawer context (#13173)

This commit is contained in:
Jacob Fletcher
2025-07-24 10:12:45 -04:00
committed by GitHub
parent 7ae4f8c709
commit e48427e59a
9 changed files with 293 additions and 50 deletions

View File

@@ -1,10 +1,11 @@
'use client' 'use client'
import type { ListQuery } from 'payload' import type { CollectionSlug, ListQuery } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { hoistQueryParamsToAnd } from 'payload/shared' import { hoistQueryParamsToAnd } from 'payload/shared'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import type { ListDrawerContextProps, ListDrawerContextType } from '../ListDrawer/Provider.js'
import type { ListDrawerProps } from './types.js' import type { ListDrawerProps } from './types.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
@@ -25,7 +26,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
onBulkSelect, onBulkSelect,
onSelect, onSelect,
overrideEntityVisibility = true, overrideEntityVisibility = true,
selectedCollection: selectedCollectionFromProps, selectedCollection: collectionSlugFromProps,
}) => { }) => {
const { closeModal, isModalOpen } = useModal() const { closeModal, isModalOpen } = useModal()
@@ -45,7 +46,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}) })
const [selectedOption, setSelectedOption] = useState<Option<string>>(() => { const [selectedOption, setSelectedOption] = useState<Option<string>>(() => {
const initialSelection = selectedCollectionFromProps || enabledCollections[0]?.slug const initialSelection = collectionSlugFromProps || enabledCollections[0]?.slug
const found = getEntityConfig({ collectionSlug: initialSelection }) const found = getEntityConfig({ collectionSlug: initialSelection })
return found return found
@@ -61,20 +62,25 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
collectionSlug: selectedOption.value, collectionSlug: selectedOption.value,
}) })
const updateSelectedOption = useEffectEvent((selectedCollectionFromProps: string) => { const updateSelectedOption = useEffectEvent((collectionSlug: CollectionSlug) => {
if (selectedCollectionFromProps && selectedCollectionFromProps !== selectedOption?.value) { if (collectionSlug && collectionSlug !== selectedOption?.value) {
setSelectedOption({ setSelectedOption({
label: getEntityConfig({ collectionSlug: selectedCollectionFromProps })?.labels, label: getEntityConfig({ collectionSlug })?.labels,
value: selectedCollectionFromProps, value: collectionSlug,
}) })
} }
}) })
useEffect(() => { useEffect(() => {
updateSelectedOption(selectedCollectionFromProps) updateSelectedOption(collectionSlugFromProps)
}, [selectedCollectionFromProps]) }, [collectionSlugFromProps])
const renderList = useCallback( /**
* This performs a full server round trip to get the list view for the selected collection.
* On the server, the data is freshly queried for the list view and all components are fully rendered.
* This work includes building column state, rendering custom components, etc.
*/
const refresh = useCallback(
async ({ slug, query }: { query?: ListQuery; slug: string }) => { async ({ slug, query }: { query?: ListQuery; slug: string }) => {
try { try {
const newQuery: ListQuery = { ...(query || {}), where: { ...(query?.where || {}) } } const newQuery: ListQuery = { ...(query || {}), where: { ...(query?.where || {}) } }
@@ -129,9 +135,9 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
useEffect(() => { useEffect(() => {
if (!ListView) { if (!ListView) {
void renderList({ slug: selectedOption?.value }) void refresh({ slug: selectedOption?.value })
} }
}, [renderList, ListView, selectedOption.value]) }, [refresh, ListView, selectedOption.value])
const onCreateNew = useCallback( const onCreateNew = useCallback(
({ doc }) => { ({ doc }) => {
@@ -149,19 +155,33 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
[closeModal, documentDrawerSlug, drawerSlug, onSelect, selectedOption.value], [closeModal, documentDrawerSlug, drawerSlug, onSelect, selectedOption.value],
) )
const onQueryChange = useCallback( const onQueryChange: ListDrawerContextProps['onQueryChange'] = useCallback(
(query: ListQuery) => { (query) => {
void renderList({ slug: selectedOption?.value, query }) void refresh({ slug: selectedOption?.value, query })
}, },
[renderList, selectedOption.value], [refresh, selectedOption.value],
) )
const setMySelectedOption = useCallback( const setMySelectedOption: ListDrawerContextProps['setSelectedOption'] = useCallback(
(incomingSelection: Option<string>) => { (incomingSelection) => {
setSelectedOption(incomingSelection) setSelectedOption(incomingSelection)
void renderList({ slug: incomingSelection?.value }) void refresh({ slug: incomingSelection?.value })
}, },
[renderList], [refresh],
)
const refreshSelf: ListDrawerContextType['refresh'] = useCallback(
async (incomingCollectionSlug) => {
if (incomingCollectionSlug) {
setSelectedOption({
label: getEntityConfig({ collectionSlug: incomingCollectionSlug })?.labels,
value: incomingCollectionSlug,
})
}
await refresh({ slug: selectedOption.value || incomingCollectionSlug })
},
[getEntityConfig, refresh, selectedOption.value],
) )
if (isLoading) { if (isLoading) {
@@ -178,6 +198,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
onBulkSelect={onBulkSelect} onBulkSelect={onBulkSelect}
onQueryChange={onQueryChange} onQueryChange={onQueryChange}
onSelect={onSelect} onSelect={onSelect}
refresh={refreshSelf}
selectedOption={selectedOption} selectedOption={selectedOption}
setSelectedOption={setMySelectedOption} setSelectedOption={setMySelectedOption}
> >

View File

@@ -24,12 +24,17 @@ export type ListDrawerContextProps = {
*/ */
docID: string docID: string
}) => void }) => void
readonly selectedOption?: Option<string> readonly selectedOption?: Option<CollectionSlug>
readonly setSelectedOption?: (option: Option<string>) => void readonly setSelectedOption?: (option: Option<CollectionSlug>) => void
} }
export type ListDrawerContextType = { export type ListDrawerContextType = {
isInDrawer: boolean readonly isInDrawer: boolean
/**
* When called, will either refresh the list view with its currently selected collection.
* If an collection slug is provided, will use that instead of the currently selected one.
*/
readonly refresh: (collectionSlug?: CollectionSlug) => Promise<void>
} & ListDrawerContextProps } & ListDrawerContextProps
export const ListDrawerContext = createContext({} as ListDrawerContextType) export const ListDrawerContext = createContext({} as ListDrawerContextType)
@@ -37,6 +42,7 @@ export const ListDrawerContext = createContext({} as ListDrawerContextType)
export const ListDrawerContextProvider: React.FC< export const ListDrawerContextProvider: React.FC<
{ {
children: React.ReactNode children: React.ReactNode
refresh: ListDrawerContextType['refresh']
} & ListDrawerContextProps } & ListDrawerContextProps
> = ({ children, ...rest }) => { > = ({ children, ...rest }) => {
return ( return (

View File

@@ -51,6 +51,25 @@ export const ListDrawer: React.FC<ListDrawerProps> = (props) => {
) )
} }
/**
* Returns an array containing the ListDrawer component, the ListDrawerToggler component, and an object with state and methods for controlling the drawer.
* @example
* import { useListDrawer } from '@payloadcms/ui'
*
* // inside a React component
* const [ListDrawer, ListDrawerToggler, { closeDrawer, openDrawer }] = useListDrawer({
* collectionSlugs: ['users'],
* selectedCollection: 'users',
* })
*
* // inside the return statement
* return (
* <>
* <ListDrawer />
* <ListDrawerToggler onClick={openDrawer}>Open List Drawer</ListDrawerToggler>
* </>
* )
*/
export const useListDrawer: UseListDrawer = ({ export const useListDrawer: UseListDrawer = ({
collectionSlugs: collectionSlugsFromProps, collectionSlugs: collectionSlugsFromProps,
filterOptions, filterOptions,

View File

@@ -0,0 +1,60 @@
'use client'
import { toast, useListDrawer, useListDrawerContext, useTranslation } from '@payloadcms/ui'
import React, { useCallback } from 'react'
export const CustomListDrawer = () => {
const [isCreating, setIsCreating] = React.useState(false)
// this is the _outer_ drawer context (if any), not the one for the list drawer below
const { refresh } = useListDrawerContext()
const { t } = useTranslation()
const [ListDrawer, ListDrawerToggler] = useListDrawer({
collectionSlugs: ['custom-list-drawer'],
})
const createDoc = useCallback(async () => {
if (isCreating) {
return
}
setIsCreating(true)
try {
await fetch('/api/custom-list-drawer', {
body: JSON.stringify({}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
setIsCreating(false)
toast.success(
t('general:successfullyCreated', {
label: 'Custom List Drawer',
}),
)
// In the root document view, there is no outer drawer context, so this will be `undefined`
if (typeof refresh === 'function') {
await refresh()
}
} catch (_err) {
console.error('Error creating document:', _err) // eslint-disable-line no-console
setIsCreating(false)
}
}, [isCreating, refresh, t])
return (
<div>
<button id="create-custom-list-drawer-doc" onClick={createDoc} type="button">
{isCreating ? 'Creating...' : 'Create Document'}
</button>
<ListDrawer />
<ListDrawerToggler id="open-custom-list-drawer">Open list drawer</ListDrawerToggler>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const CustomListDrawer: CollectionConfig = {
slug: 'custom-list-drawer',
fields: [
{
name: 'customListDrawer',
type: 'ui',
admin: {
components: {
Field: '/collections/CustomListDrawer/Component.js#CustomListDrawer',
},
},
},
],
}

View File

@@ -5,6 +5,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Array } from './collections/Array.js' import { Array } from './collections/Array.js'
import { BaseListFilter } from './collections/BaseListFilter.js' import { BaseListFilter } from './collections/BaseListFilter.js'
import { CustomFields } from './collections/CustomFields/index.js' import { CustomFields } from './collections/CustomFields/index.js'
import { CustomListDrawer } from './collections/CustomListDrawer/index.js'
import { CustomViews1 } from './collections/CustomViews1.js' import { CustomViews1 } from './collections/CustomViews1.js'
import { CustomViews2 } from './collections/CustomViews2.js' import { CustomViews2 } from './collections/CustomViews2.js'
import { DisableBulkEdit } from './collections/DisableBulkEdit.js' import { DisableBulkEdit } from './collections/DisableBulkEdit.js'
@@ -185,6 +186,7 @@ export default buildConfigWithDefaults({
Placeholder, Placeholder,
UseAsTitleGroupField, UseAsTitleGroupField,
DisableBulkEdit, DisableBulkEdit,
CustomListDrawer,
], ],
globals: [ globals: [
GlobalHidden, GlobalHidden,

View File

@@ -1676,6 +1676,42 @@ describe('List View', () => {
await expect(page.locator('.list-selection')).toContainText('2 selected') await expect(page.locator('.list-selection')).toContainText('2 selected')
}) })
test('should refresh custom list drawer using the refresh method from context', async () => {
const url = new AdminUrlUtil(serverURL, 'custom-list-drawer')
await payload.delete({
collection: 'custom-list-drawer',
where: { id: { exists: true } },
})
const { id } = await payload.create({
collection: 'custom-list-drawer',
data: {},
})
await page.goto(url.list)
await expect(page.locator('.table > table > tbody > tr')).toHaveCount(1)
await page.goto(url.edit(id))
await page.locator('#open-custom-list-drawer').click()
const drawer = page.locator('[id^=list-drawer_1_]')
await expect(drawer).toBeVisible()
await expect(drawer.locator('.table > table > tbody > tr')).toHaveCount(1)
await drawer.locator('.list-header__create-new-button.doc-drawer__toggler').click()
const createNewDrawer = page.locator('[id^=doc-drawer_custom-list-drawer_1_]')
await createNewDrawer.locator('#create-custom-list-drawer-doc').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await createNewDrawer.locator('.doc-drawer__header-close').click()
await expect(drawer.locator('.table > table > tbody > tr')).toHaveCount(2)
})
}) })
async function createPost(overrides?: Partial<Post>): Promise<Post> { async function createPost(overrides?: Partial<Post>): Promise<Post> {

View File

@@ -93,6 +93,7 @@ export interface Config {
placeholder: Placeholder; placeholder: Placeholder;
'use-as-title-group-field': UseAsTitleGroupField; 'use-as-title-group-field': UseAsTitleGroupField;
'disable-bulk-edit': DisableBulkEdit; 'disable-bulk-edit': DisableBulkEdit;
'custom-list-drawer': CustomListDrawer;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
@@ -125,6 +126,7 @@ export interface Config {
placeholder: PlaceholderSelect<false> | PlaceholderSelect<true>; placeholder: PlaceholderSelect<false> | PlaceholderSelect<true>;
'use-as-title-group-field': UseAsTitleGroupFieldSelect<false> | UseAsTitleGroupFieldSelect<true>; 'use-as-title-group-field': UseAsTitleGroupFieldSelect<false> | UseAsTitleGroupFieldSelect<true>;
'disable-bulk-edit': DisableBulkEditSelect<false> | DisableBulkEditSelect<true>; 'disable-bulk-edit': DisableBulkEditSelect<false> | DisableBulkEditSelect<true>;
'custom-list-drawer': CustomListDrawerSelect<false> | CustomListDrawerSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -565,6 +567,15 @@ export interface DisableBulkEdit {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-list-drawer".
*/
export interface CustomListDrawer {
id: string;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
@@ -675,6 +686,10 @@ export interface PayloadLockedDocument {
| ({ | ({
relationTo: 'disable-bulk-edit'; relationTo: 'disable-bulk-edit';
value: string | DisableBulkEdit; value: string | DisableBulkEdit;
} | null)
| ({
relationTo: 'custom-list-drawer';
value: string | CustomListDrawer;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
@@ -1074,6 +1089,14 @@ export interface DisableBulkEditSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-list-drawer_select".
*/
export interface CustomListDrawerSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select". * via the `definition` "payload-locked-documents_select".

View File

@@ -21,8 +21,15 @@
"skipLibCheck": true, "skipLibCheck": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"sourceMap": true, "sourceMap": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": [
"types": ["node", "jest"], "DOM",
"DOM.Iterable",
"ES2022"
],
"types": [
"node",
"jest"
],
"incremental": true, "incremental": true,
"isolatedModules": true, "isolatedModules": true,
"plugins": [ "plugins": [
@@ -31,36 +38,72 @@
} }
], ],
"paths": { "paths": {
"@payload-config": ["./test/_community/config.ts"], "@payload-config": [
"@payloadcms/admin-bar": ["./packages/admin-bar/src"], "./test/admin/config.ts"
"@payloadcms/live-preview": ["./packages/live-preview/src"], ],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/admin-bar": [
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], "./packages/admin-bar/src"
"@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"], ],
"@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"], "@payloadcms/live-preview": [
"@payloadcms/ui/rsc": ["./packages/ui/src/exports/rsc/index.ts"], "./packages/live-preview/src"
"@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"], ],
"@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"], "@payloadcms/live-preview-react": [
"@payloadcms/next/*": ["./packages/next/src/exports/*.ts"], "./packages/live-preview-react/src/index.ts"
],
"@payloadcms/live-preview-vue": [
"./packages/live-preview-vue/src/index.ts"
],
"@payloadcms/ui": [
"./packages/ui/src/exports/client/index.ts"
],
"@payloadcms/ui/shared": [
"./packages/ui/src/exports/shared/index.ts"
],
"@payloadcms/ui/rsc": [
"./packages/ui/src/exports/rsc/index.ts"
],
"@payloadcms/ui/scss": [
"./packages/ui/src/scss.scss"
],
"@payloadcms/ui/scss/app.scss": [
"./packages/ui/src/scss/app.scss"
],
"@payloadcms/next/*": [
"./packages/next/src/exports/*.ts"
],
"@payloadcms/richtext-lexical/client": [ "@payloadcms/richtext-lexical/client": [
"./packages/richtext-lexical/src/exports/client/index.ts" "./packages/richtext-lexical/src/exports/client/index.ts"
], ],
"@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"], "@payloadcms/richtext-lexical/rsc": [
"@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"], "./packages/richtext-lexical/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-slate/rsc": [
"./packages/richtext-slate/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-slate/client": [ "@payloadcms/richtext-slate/client": [
"./packages/richtext-slate/src/exports/client/index.ts" "./packages/richtext-slate/src/exports/client/index.ts"
], ],
"@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"], "@payloadcms/plugin-seo/client": [
"@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"], "./packages/plugin-seo/src/exports/client.ts"
"@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"], ],
"@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"], "@payloadcms/plugin-sentry/client": [
"./packages/plugin-sentry/src/exports/client.ts"
],
"@payloadcms/plugin-stripe/client": [
"./packages/plugin-stripe/src/exports/client.ts"
],
"@payloadcms/plugin-search/client": [
"./packages/plugin-search/src/exports/client.ts"
],
"@payloadcms/plugin-form-builder/client": [ "@payloadcms/plugin-form-builder/client": [
"./packages/plugin-form-builder/src/exports/client.ts" "./packages/plugin-form-builder/src/exports/client.ts"
], ],
"@payloadcms/plugin-import-export/rsc": [ "@payloadcms/plugin-import-export/rsc": [
"./packages/plugin-import-export/src/exports/rsc.ts" "./packages/plugin-import-export/src/exports/rsc.ts"
], ],
"@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], "@payloadcms/plugin-multi-tenant/rsc": [
"./packages/plugin-multi-tenant/src/exports/rsc.ts"
],
"@payloadcms/plugin-multi-tenant/utilities": [ "@payloadcms/plugin-multi-tenant/utilities": [
"./packages/plugin-multi-tenant/src/exports/utilities.ts" "./packages/plugin-multi-tenant/src/exports/utilities.ts"
], ],
@@ -70,25 +113,42 @@
"@payloadcms/plugin-multi-tenant/client": [ "@payloadcms/plugin-multi-tenant/client": [
"./packages/plugin-multi-tenant/src/exports/client.ts" "./packages/plugin-multi-tenant/src/exports/client.ts"
], ],
"@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], "@payloadcms/plugin-multi-tenant": [
"./packages/plugin-multi-tenant/src/index.ts"
],
"@payloadcms/plugin-multi-tenant/translations/languages/all": [ "@payloadcms/plugin-multi-tenant/translations/languages/all": [
"./packages/plugin-multi-tenant/src/translations/index.ts" "./packages/plugin-multi-tenant/src/translations/index.ts"
], ],
"@payloadcms/plugin-multi-tenant/translations/languages/*": [ "@payloadcms/plugin-multi-tenant/translations/languages/*": [
"./packages/plugin-multi-tenant/src/translations/languages/*.ts" "./packages/plugin-multi-tenant/src/translations/languages/*.ts"
], ],
"@payloadcms/next": ["./packages/next/src/exports/*"], "@payloadcms/next": [
"@payloadcms/storage-azure/client": ["./packages/storage-azure/src/exports/client.ts"], "./packages/next/src/exports/*"
"@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], ],
"@payloadcms/storage-azure/client": [
"./packages/storage-azure/src/exports/client.ts"
],
"@payloadcms/storage-s3/client": [
"./packages/storage-s3/src/exports/client.ts"
],
"@payloadcms/storage-vercel-blob/client": [ "@payloadcms/storage-vercel-blob/client": [
"./packages/storage-vercel-blob/src/exports/client.ts" "./packages/storage-vercel-blob/src/exports/client.ts"
], ],
"@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"], "@payloadcms/storage-gcs/client": [
"./packages/storage-gcs/src/exports/client.ts"
],
"@payloadcms/storage-uploadthing/client": [ "@payloadcms/storage-uploadthing/client": [
"./packages/storage-uploadthing/src/exports/client.ts" "./packages/storage-uploadthing/src/exports/client.ts"
] ]
} }
}, },
"include": ["${configDir}/src"], "include": [
"exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"] "${configDir}/src"
],
"exclude": [
"${configDir}/dist",
"${configDir}/build",
"${configDir}/temp",
"**/*.spec.ts"
]
} }