diff --git a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx index 19c7c26c3..37232b8b8 100644 --- a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx @@ -1,10 +1,11 @@ 'use client' -import type { ListQuery } from 'payload' +import type { CollectionSlug, ListQuery } from 'payload' import { useModal } from '@faceless-ui/modal' import { hoistQueryParamsToAnd } from 'payload/shared' import React, { useCallback, useEffect, useState } from 'react' +import type { ListDrawerContextProps, ListDrawerContextType } from '../ListDrawer/Provider.js' import type { ListDrawerProps } from './types.js' import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' @@ -25,7 +26,7 @@ export const ListDrawerContent: React.FC = ({ onBulkSelect, onSelect, overrideEntityVisibility = true, - selectedCollection: selectedCollectionFromProps, + selectedCollection: collectionSlugFromProps, }) => { const { closeModal, isModalOpen } = useModal() @@ -45,7 +46,7 @@ export const ListDrawerContent: React.FC = ({ }) const [selectedOption, setSelectedOption] = useState>(() => { - const initialSelection = selectedCollectionFromProps || enabledCollections[0]?.slug + const initialSelection = collectionSlugFromProps || enabledCollections[0]?.slug const found = getEntityConfig({ collectionSlug: initialSelection }) return found @@ -61,20 +62,25 @@ export const ListDrawerContent: React.FC = ({ collectionSlug: selectedOption.value, }) - const updateSelectedOption = useEffectEvent((selectedCollectionFromProps: string) => { - if (selectedCollectionFromProps && selectedCollectionFromProps !== selectedOption?.value) { + const updateSelectedOption = useEffectEvent((collectionSlug: CollectionSlug) => { + if (collectionSlug && collectionSlug !== selectedOption?.value) { setSelectedOption({ - label: getEntityConfig({ collectionSlug: selectedCollectionFromProps })?.labels, - value: selectedCollectionFromProps, + label: getEntityConfig({ collectionSlug })?.labels, + value: collectionSlug, }) } }) useEffect(() => { - updateSelectedOption(selectedCollectionFromProps) - }, [selectedCollectionFromProps]) + updateSelectedOption(collectionSlugFromProps) + }, [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 }) => { try { const newQuery: ListQuery = { ...(query || {}), where: { ...(query?.where || {}) } } @@ -129,9 +135,9 @@ export const ListDrawerContent: React.FC = ({ useEffect(() => { if (!ListView) { - void renderList({ slug: selectedOption?.value }) + void refresh({ slug: selectedOption?.value }) } - }, [renderList, ListView, selectedOption.value]) + }, [refresh, ListView, selectedOption.value]) const onCreateNew = useCallback( ({ doc }) => { @@ -149,19 +155,33 @@ export const ListDrawerContent: React.FC = ({ [closeModal, documentDrawerSlug, drawerSlug, onSelect, selectedOption.value], ) - const onQueryChange = useCallback( - (query: ListQuery) => { - void renderList({ slug: selectedOption?.value, query }) + const onQueryChange: ListDrawerContextProps['onQueryChange'] = useCallback( + (query) => { + void refresh({ slug: selectedOption?.value, query }) }, - [renderList, selectedOption.value], + [refresh, selectedOption.value], ) - const setMySelectedOption = useCallback( - (incomingSelection: Option) => { + const setMySelectedOption: ListDrawerContextProps['setSelectedOption'] = useCallback( + (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) { @@ -178,6 +198,7 @@ export const ListDrawerContent: React.FC = ({ onBulkSelect={onBulkSelect} onQueryChange={onQueryChange} onSelect={onSelect} + refresh={refreshSelf} selectedOption={selectedOption} setSelectedOption={setMySelectedOption} > diff --git a/packages/ui/src/elements/ListDrawer/Provider.tsx b/packages/ui/src/elements/ListDrawer/Provider.tsx index 8aeb4ed7a..7a0156ed8 100644 --- a/packages/ui/src/elements/ListDrawer/Provider.tsx +++ b/packages/ui/src/elements/ListDrawer/Provider.tsx @@ -24,12 +24,17 @@ export type ListDrawerContextProps = { */ docID: string }) => void - readonly selectedOption?: Option - readonly setSelectedOption?: (option: Option) => void + readonly selectedOption?: Option + readonly setSelectedOption?: (option: Option) => void } 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 } & ListDrawerContextProps export const ListDrawerContext = createContext({} as ListDrawerContextType) @@ -37,6 +42,7 @@ export const ListDrawerContext = createContext({} as ListDrawerContextType) export const ListDrawerContextProvider: React.FC< { children: React.ReactNode + refresh: ListDrawerContextType['refresh'] } & ListDrawerContextProps > = ({ children, ...rest }) => { return ( diff --git a/packages/ui/src/elements/ListDrawer/index.tsx b/packages/ui/src/elements/ListDrawer/index.tsx index c4eaae618..342bdb560 100644 --- a/packages/ui/src/elements/ListDrawer/index.tsx +++ b/packages/ui/src/elements/ListDrawer/index.tsx @@ -51,6 +51,25 @@ export const ListDrawer: React.FC = (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 ( + * <> + * + * Open List Drawer + * + * ) + */ export const useListDrawer: UseListDrawer = ({ collectionSlugs: collectionSlugsFromProps, filterOptions, diff --git a/test/admin/collections/CustomListDrawer/Component.tsx b/test/admin/collections/CustomListDrawer/Component.tsx new file mode 100644 index 000000000..c3b6c09a2 --- /dev/null +++ b/test/admin/collections/CustomListDrawer/Component.tsx @@ -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 ( +
+ + + Open list drawer +
+ ) +} diff --git a/test/admin/collections/CustomListDrawer/index.ts b/test/admin/collections/CustomListDrawer/index.ts new file mode 100644 index 000000000..5a8caefc7 --- /dev/null +++ b/test/admin/collections/CustomListDrawer/index.ts @@ -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', + }, + }, + }, + ], +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 52d1d84e4..69ba64555 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -5,6 +5,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { Array } from './collections/Array.js' import { BaseListFilter } from './collections/BaseListFilter.js' import { CustomFields } from './collections/CustomFields/index.js' +import { CustomListDrawer } from './collections/CustomListDrawer/index.js' import { CustomViews1 } from './collections/CustomViews1.js' import { CustomViews2 } from './collections/CustomViews2.js' import { DisableBulkEdit } from './collections/DisableBulkEdit.js' @@ -185,6 +186,7 @@ export default buildConfigWithDefaults({ Placeholder, UseAsTitleGroupField, DisableBulkEdit, + CustomListDrawer, ], globals: [ GlobalHidden, diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 2e8ec07cc..bdcc5ef83 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -1676,6 +1676,42 @@ describe('List View', () => { 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): Promise { diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index 10e34bc1b..0a739c4fa 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -93,6 +93,7 @@ export interface Config { placeholder: Placeholder; 'use-as-title-group-field': UseAsTitleGroupField; 'disable-bulk-edit': DisableBulkEdit; + 'custom-list-drawer': CustomListDrawer; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -125,6 +126,7 @@ export interface Config { placeholder: PlaceholderSelect | PlaceholderSelect; 'use-as-title-group-field': UseAsTitleGroupFieldSelect | UseAsTitleGroupFieldSelect; 'disable-bulk-edit': DisableBulkEditSelect | DisableBulkEditSelect; + 'custom-list-drawer': CustomListDrawerSelect | CustomListDrawerSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -565,6 +567,15 @@ export interface DisableBulkEdit { updatedAt: 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 * via the `definition` "payload-locked-documents". @@ -675,6 +686,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'disable-bulk-edit'; value: string | DisableBulkEdit; + } | null) + | ({ + relationTo: 'custom-list-drawer'; + value: string | CustomListDrawer; } | null); globalSlug?: string | null; user: { @@ -1074,6 +1089,14 @@ export interface DisableBulkEditSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "custom-list-drawer_select". + */ +export interface CustomListDrawerSelect { + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". diff --git a/tsconfig.base.json b/tsconfig.base.json index 0898ad390..5e4e34350 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,8 +21,15 @@ "skipLibCheck": true, "emitDeclarationOnly": true, "sourceMap": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "jest"], + "lib": [ + "DOM", + "DOM.Iterable", + "ES2022" + ], + "types": [ + "node", + "jest" + ], "incremental": true, "isolatedModules": true, "plugins": [ @@ -31,36 +38,72 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], - "@payloadcms/admin-bar": ["./packages/admin-bar/src"], - "@payloadcms/live-preview": ["./packages/live-preview/src"], - "@payloadcms/live-preview-react": ["./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"], + "@payload-config": [ + "./test/admin/config.ts" + ], + "@payloadcms/admin-bar": [ + "./packages/admin-bar/src" + ], + "@payloadcms/live-preview": [ + "./packages/live-preview/src" + ], + "@payloadcms/live-preview-react": [ + "./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": [ "./packages/richtext-lexical/src/exports/client/index.ts" ], - "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"], - "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"], + "@payloadcms/richtext-lexical/rsc": [ + "./packages/richtext-lexical/src/exports/server/rsc.ts" + ], + "@payloadcms/richtext-slate/rsc": [ + "./packages/richtext-slate/src/exports/server/rsc.ts" + ], "@payloadcms/richtext-slate/client": [ "./packages/richtext-slate/src/exports/client/index.ts" ], - "@payloadcms/plugin-seo/client": ["./packages/plugin-seo/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-seo/client": [ + "./packages/plugin-seo/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": [ "./packages/plugin-form-builder/src/exports/client.ts" ], "@payloadcms/plugin-import-export/rsc": [ "./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": [ "./packages/plugin-multi-tenant/src/exports/utilities.ts" ], @@ -70,25 +113,42 @@ "@payloadcms/plugin-multi-tenant/client": [ "./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": [ "./packages/plugin-multi-tenant/src/translations/index.ts" ], "@payloadcms/plugin-multi-tenant/translations/languages/*": [ "./packages/plugin-multi-tenant/src/translations/languages/*.ts" ], - "@payloadcms/next": ["./packages/next/src/exports/*"], - "@payloadcms/storage-azure/client": ["./packages/storage-azure/src/exports/client.ts"], - "@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], + "@payloadcms/next": [ + "./packages/next/src/exports/*" + ], + "@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": [ "./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": [ "./packages/storage-uploadthing/src/exports/client.ts" ] } }, - "include": ["${configDir}/src"], - "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"] + "include": [ + "${configDir}/src" + ], + "exclude": [ + "${configDir}/dist", + "${configDir}/build", + "${configDir}/temp", + "**/*.spec.ts" + ] }