From a43d1a685f52c24ff8614424fbcbe9ed16c43264 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:56:28 -0400 Subject: [PATCH] feat(ui): moves folder rendering from the client to the server (#12710) --- .../src/utilities/handleServerFunctions.ts | 3 + .../src/views/BrowseByFolder/buildView.tsx | 132 +-- .../src/views/CollectionFolders/buildView.tsx | 136 ++- .../src/views/LivePreview/index.client.tsx | 2 +- packages/payload/src/admin/functions/index.ts | 9 + packages/payload/src/admin/types.ts | 1 + .../payload/src/admin/views/folderList.ts | 19 +- packages/payload/src/folders/types.ts | 7 + packages/payload/src/index.ts | 1 + packages/ui/package.json | 5 + .../FolderView/CollectionTypePill/index.tsx | 12 +- .../FolderView/CurrentFolderActions/index.tsx | 31 +- .../Drawers/EditFolderAction/index.tsx | 11 +- .../FolderView/Drawers/MoveToFolder/index.tsx | 163 +-- .../FolderView/FolderFileCard/index.tsx | 41 + .../FolderView/FolderFileTable/index.tsx | 83 +- .../FolderView/ItemCardGrid/index.tsx | 53 +- .../elements/FolderView/SortByPill/index.tsx | 32 +- .../ListCreateNewDocInFolderButton.tsx | 18 +- packages/ui/src/exports/rsc/index.ts | 1 + packages/ui/src/fields/Relationship/Input.tsx | 3 +- packages/ui/src/providers/Folders/index.tsx | 987 +++++------------- .../src/providers/ServerFunctions/index.tsx | 30 + .../ui/src/providers/TableColumns/index.tsx | 2 +- .../getFolderResultsComponentAndData.tsx | 170 +++ .../ui/src/views/BrowseByFolder/index.tsx | 195 ++-- .../CollectionFolder/ListSelection/index.tsx | 34 +- .../ui/src/views/CollectionFolder/index.tsx | 261 ++--- test/folders/payload-types.ts | 4 +- 29 files changed, 1073 insertions(+), 1373 deletions(-) create mode 100644 packages/ui/src/utilities/getFolderResultsComponentAndData.tsx diff --git a/packages/next/src/utilities/handleServerFunctions.ts b/packages/next/src/utilities/handleServerFunctions.ts index 44f96d577..a4737ebe9 100644 --- a/packages/next/src/utilities/handleServerFunctions.ts +++ b/packages/next/src/utilities/handleServerFunctions.ts @@ -3,6 +3,7 @@ import type { ServerFunction, ServerFunctionHandler } from 'payload' import { copyDataFromLocaleHandler } from '@payloadcms/ui/rsc' import { buildFormStateHandler } from '@payloadcms/ui/utilities/buildFormState' import { buildTableStateHandler } from '@payloadcms/ui/utilities/buildTableState' +import { getFolderResultsComponentAndDataHandler } from '@payloadcms/ui/utilities/getFolderResultsComponentAndData' import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublishHandler' import { renderDocumentHandler } from '../views/Document/handleServerFunction.js' @@ -28,6 +29,8 @@ export const handleServerFunctions: ServerFunctionHandler = async (args) => { const serverFunctions = { 'copy-data-from-locale': copyDataFromLocaleHandler as any as ServerFunction, 'form-state': buildFormStateHandler as any as ServerFunction, + 'get-folder-results-component-and-data': + getFolderResultsComponentAndDataHandler as any as ServerFunction, 'render-document': renderDocumentHandler as any as ServerFunction, 'render-document-slots': renderDocumentSlotsHandler as any as ServerFunction, 'render-list': renderListHandler as any as ServerFunction, diff --git a/packages/next/src/views/BrowseByFolder/buildView.tsx b/packages/next/src/views/BrowseByFolder/buildView.tsx index fa7d64e19..b57da8a60 100644 --- a/packages/next/src/views/BrowseByFolder/buildView.tsx +++ b/packages/next/src/views/BrowseByFolder/buildView.tsx @@ -1,21 +1,19 @@ import type { AdminViewServerProps, BuildCollectionFolderViewResult, + FolderListViewClientProps, FolderListViewServerPropsOnly, + FolderSortKeys, ListQuery, - Where, } from 'payload' -import { DefaultBrowseByFolderView, FolderProvider, HydrateAuthProvider } from '@payloadcms/ui' +import { DefaultBrowseByFolderView, HydrateAuthProvider } from '@payloadcms/ui' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' +import { getFolderResultsComponentAndData, upsertPreferences } from '@payloadcms/ui/rsc' import { formatAdminURL } from '@payloadcms/ui/shared' import { redirect } from 'next/navigation.js' -import { getFolderData } from 'payload' -import { buildFolderWhereConstraints } from 'payload/shared' import React from 'react' -import { getPreferences } from '../../utilities/getPreferences.js' - export type BuildFolderViewArgs = { customCellProps?: Record disableBulkDelete?: boolean @@ -56,18 +54,18 @@ export const buildBrowseByFolderView = async ( visibleEntities, } = initPageResult + if (config.folders === false || config.folders.browseByFolder === false) { + throw new Error('not-found') + } + const browseByFolderSlugs = browseByFolderSlugsFromArgs.filter( (collectionSlug) => permissions?.collections?.[collectionSlug]?.read && visibleEntities.collections.includes(collectionSlug), ) - if (config.folders === false || config.folders.browseByFolder === false) { - throw new Error('not-found') - } - const query = queryFromArgs || queryFromReq - const selectedCollectionSlugs: string[] = + const activeCollectionFolderSlugs: string[] = Array.isArray(query?.relationTo) && query.relationTo.length ? query.relationTo.filter( (slug) => @@ -79,62 +77,35 @@ export const buildBrowseByFolderView = async ( routes: { admin: adminRoute }, } = config - const folderCollectionConfig = payload.collections[config.folders.slug].config - - const browseByFolderPreferences = await getPreferences<{ viewPreference: string }>( - 'browse-by-folder', - payload, - user.id, - user.collection, - ) - - let documentWhere: undefined | Where = undefined - let folderWhere: undefined | Where = undefined - // if folderID, dont make a documentWhere since it only queries root folders - for (const collectionSlug of selectedCollectionSlugs) { - if (collectionSlug === config.folders.slug) { - const folderCollectionConstraints = await buildFolderWhereConstraints({ - collectionConfig: folderCollectionConfig, - folderID, - localeCode: fullLocale?.code, - req: initPageResult.req, - search: typeof query?.search === 'string' ? query.search : undefined, - }) - - if (folderCollectionConstraints) { - folderWhere = folderCollectionConstraints - } - } else if (folderID) { - if (!documentWhere) { - documentWhere = { - or: [], - } - } - - const collectionConfig = payload.collections[collectionSlug].config - if (collectionConfig.folders && collectionConfig.folders.browseByFolder === true) { - const collectionConstraints = await buildFolderWhereConstraints({ - collectionConfig, - folderID, - localeCode: fullLocale?.code, - req: initPageResult.req, - search: typeof query?.search === 'string' ? query.search : undefined, - }) - - if (collectionConstraints) { - documentWhere.or.push(collectionConstraints) - } - } - } - } - - const { breadcrumbs, documents, subfolders } = await getFolderData({ - documentWhere, - folderID, - folderWhere, + /** + * @todo: find a pattern to avoid setting preferences on hard navigation, i.e. direct links, page refresh, etc. + * This will ensure that prefs are only updated when explicitly set by the user + * This could potentially be done by injecting a `sessionID` into the params and comparing it against a session cookie + */ + const browseByFolderPreferences = await upsertPreferences<{ + sort?: FolderSortKeys + viewPreference?: 'grid' | 'list' + }>({ + key: 'browse-by-folder', req: initPageResult.req, + value: { + sort: query?.sort as FolderSortKeys, + }, }) + const sortPreference: FolderSortKeys = browseByFolderPreferences?.sort || '_folderOrDocumentTitle' + const viewPreference = browseByFolderPreferences?.viewPreference || 'grid' + + const { breadcrumbs, documents, FolderResultsComponent, subfolders } = + await getFolderResultsComponentAndData({ + activeCollectionSlugs: activeCollectionFolderSlugs, + browseByFolder: false, + displayAs: viewPreference, + folderID, + req: initPageResult.req, + sort: sortPreference, + }) + const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id if ( @@ -172,38 +143,39 @@ export const buildBrowseByFolderView = async ( // }) // documents cannot be created without a parent folder in this view - const hasCreatePermissionCollectionSlugs = folderID + const allowCreateCollectionSlugs = resolvedFolderID ? [config.folders.slug, ...browseByFolderSlugs] : [config.folders.slug] return { View: ( - + <> {RenderServerComponent({ clientProps: { // ...folderViewSlots, + activeCollectionFolderSlugs, + allCollectionFolderSlugs: browseByFolderSlugs, + allowCreateCollectionSlugs, + baseFolderPath: `/browse-by-folder`, + breadcrumbs, disableBulkDelete, disableBulkEdit, + documents, enableRowSelections, - hasCreatePermissionCollectionSlugs, - selectedCollectionSlugs, - viewPreference: browseByFolderPreferences?.value?.viewPreference, - }, - // Component:config.folders?.components?.views?.list?.Component, + folderFieldName: config.folders.fieldName, + folderID: resolvedFolderID || null, + FolderResultsComponent, + sort: sortPreference, + subfolders, + viewPreference, + } satisfies FolderListViewClientProps, + // Component:config.folders?.components?.views?.BrowseByFolders?.Component, Fallback: DefaultBrowseByFolderView, importMap: payload.importMap, serverProps, })} - + ), } } diff --git a/packages/next/src/views/CollectionFolders/buildView.tsx b/packages/next/src/views/CollectionFolders/buildView.tsx index 167f4f428..e06bfcc2c 100644 --- a/packages/next/src/views/CollectionFolders/buildView.tsx +++ b/packages/next/src/views/CollectionFolders/buildView.tsx @@ -1,21 +1,19 @@ import type { AdminViewServerProps, BuildCollectionFolderViewResult, + FolderListViewClientProps, FolderListViewServerPropsOnly, + FolderSortKeys, ListQuery, - Where, } from 'payload' -import { DefaultCollectionFolderView, FolderProvider, HydrateAuthProvider } from '@payloadcms/ui' +import { DefaultCollectionFolderView, HydrateAuthProvider } from '@payloadcms/ui' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' +import { getFolderResultsComponentAndData, upsertPreferences } from '@payloadcms/ui/rsc' import { formatAdminURL } from '@payloadcms/ui/shared' import { redirect } from 'next/navigation.js' -import { getFolderData } from 'payload' -import { buildFolderWhereConstraints } from 'payload/shared' import React from 'react' -import { getPreferences } from '../../utilities/getPreferences.js' - // import { renderFolderViewSlots } from './renderFolderViewSlots.js' export type BuildCollectionFolderViewStateArgs = { @@ -62,24 +60,18 @@ export const buildCollectionFolderView = async ( visibleEntities, } = initPageResult - if (!permissions?.collections?.[collectionSlug]?.read) { + if (!config.folders) { + throw new Error('not-found') + } + + if ( + !permissions?.collections?.[collectionSlug]?.read || + !permissions?.collections?.[config.folders.slug].read + ) { throw new Error('not-found') } if (collectionConfig) { - const query = queryFromArgs || queryFromReq - - const collectionFolderPreferences = await getPreferences<{ - sort?: string - viewPreference: string - }>(`${collectionSlug}-collection-folder`, payload, user.id, user.collection) - - const sortPreference = collectionFolderPreferences?.value.sort - - const { - routes: { admin: adminRoute }, - } = config - if ( (!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) || !config.folders @@ -87,41 +79,41 @@ export const buildCollectionFolderView = async ( throw new Error('not-found') } - let folderWhere: undefined | Where - const folderCollectionConfig = payload.collections[config.folders.slug].config - const folderCollectionConstraints = await buildFolderWhereConstraints({ - collectionConfig: folderCollectionConfig, - folderID, - localeCode: fullLocale?.code, + const query = queryFromArgs || queryFromReq + + /** + * @todo: find a pattern to avoid setting preferences on hard navigation, i.e. direct links, page refresh, etc. + * This will ensure that prefs are only updated when explicitly set by the user + * This could potentially be done by injecting a `sessionID` into the params and comparing it against a session cookie + */ + const collectionFolderPreferences = await upsertPreferences<{ + sort?: FolderSortKeys + viewPreference?: 'grid' | 'list' + }>({ + key: `${collectionSlug}-collection-folder`, req: initPageResult.req, - search: typeof query?.search === 'string' ? query.search : undefined, - sort: sortPreference, + value: { + sort: query?.sort as FolderSortKeys, + }, }) - if (folderCollectionConstraints) { - folderWhere = folderCollectionConstraints - } + const sortPreference: FolderSortKeys = + collectionFolderPreferences?.sort || '_folderOrDocumentTitle' + const viewPreference = collectionFolderPreferences?.viewPreference || 'grid' - let documentWhere: undefined | Where - const collectionConstraints = await buildFolderWhereConstraints({ - collectionConfig, - folderID, - localeCode: fullLocale?.code, - req: initPageResult.req, - search: typeof query?.search === 'string' ? query.search : undefined, - sort: sortPreference, - }) - if (collectionConstraints) { - documentWhere = collectionConstraints - } + const { + routes: { admin: adminRoute }, + } = config - const { breadcrumbs, documents, subfolders } = await getFolderData({ - collectionSlug, - documentWhere, - folderID, - folderWhere, - req: initPageResult.req, - }) + const { breadcrumbs, documents, FolderResultsComponent, subfolders } = + await getFolderResultsComponentAndData({ + activeCollectionSlugs: [config.folders.slug, collectionSlug], + browseByFolder: false, + displayAs: viewPreference, + folderID, + req: initPageResult.req, + sort: sortPreference, + }) const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id @@ -139,13 +131,6 @@ export const buildCollectionFolderView = async ( ) } - const newDocumentURL = formatAdminURL({ - adminRoute, - path: `/collections/${collectionSlug}/create`, - }) - - const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create - const serverProps: FolderListViewServerPropsOnly = { collectionConfig, documents, @@ -178,34 +163,39 @@ export const buildCollectionFolderView = async ( return { View: ( - + <> {RenderServerComponent({ clientProps: { // ...folderViewSlots, + allCollectionFolderSlugs: [config.folders.slug, collectionSlug], + allowCreateCollectionSlugs: [ + permissions?.collections?.[config.folders.slug]?.create + ? config.folders.slug + : null, + permissions?.collections?.[collectionSlug]?.create ? collectionSlug : null, + ].filter(Boolean), + baseFolderPath: `/collections/${collectionSlug}/${config.folders.slug}`, + breadcrumbs, collectionSlug, disableBulkDelete, disableBulkEdit, + documents, enableRowSelections, - hasCreatePermission, - newDocumentURL, - viewPreference: collectionFolderPreferences?.value?.viewPreference, - }, - Component: collectionConfig?.admin?.components?.views?.list?.Component, + folderFieldName: config.folders.fieldName, + folderID: resolvedFolderID || null, + FolderResultsComponent, + search, + sort: sortPreference, + subfolders, + viewPreference, + } satisfies FolderListViewClientProps, + // Component: collectionConfig?.admin?.components?.views?.Folders?.Component, Fallback: DefaultCollectionFolderView, importMap: payload.importMap, serverProps, })} - + ), } } diff --git a/packages/next/src/views/LivePreview/index.client.tsx b/packages/next/src/views/LivePreview/index.client.tsx index d5b03c985..5eb0c7aaa 100644 --- a/packages/next/src/views/LivePreview/index.client.tsx +++ b/packages/next/src/views/LivePreview/index.client.tsx @@ -427,7 +427,7 @@ const PreviewView: React.FC = ({ : currentEditor !== user?.id) && !isReadOnlyForIncomingUser && !showTakeOverModal && - + // eslint-disable-next-line react-compiler/react-compiler !documentLockStateRef.current?.hasShownLockedModal && !isLockExpired diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index 6cdab2fc7..40062ed2e 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -75,3 +75,12 @@ export type BuildTableStateArgs = { export type BuildCollectionFolderViewResult = { View: React.ReactNode } + +export type GetFolderResultsComponentAndDataArgs = { + activeCollectionSlugs: CollectionSlug[] + browseByFolder: boolean + displayAs: 'grid' | 'list' + folderID: number | string | undefined + req: PayloadRequest + sort: string +} diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index 1422aa431..653278660 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -572,6 +572,7 @@ export type { BuildCollectionFolderViewResult, BuildTableStateArgs, DefaultServerFunctionArgs, + GetFolderResultsComponentAndDataArgs, ListQuery, ServerFunction, ServerFunctionArgs, diff --git a/packages/payload/src/admin/views/folderList.ts b/packages/payload/src/admin/views/folderList.ts index a4ea4bf28..b1074fe46 100644 --- a/packages/payload/src/admin/views/folderList.ts +++ b/packages/payload/src/admin/views/folderList.ts @@ -1,5 +1,5 @@ import type { ServerProps } from '../../config/types.js' -import type { FolderOrDocument } from '../../folders/types.js' +import type { FolderBreadcrumb, FolderOrDocument, FolderSortKeys } from '../../folders/types.js' import type { SanitizedCollectionConfig } from '../../index.js' export type FolderListViewSlots = { AfterFolderList?: React.ReactNode @@ -8,7 +8,6 @@ export type FolderListViewSlots = { BeforeFolderListTable?: React.ReactNode Description?: React.ReactNode listMenuItems?: React.ReactNode[] - Table: React.ReactNode } export type FolderListViewServerPropsOnly = { @@ -20,13 +19,23 @@ export type FolderListViewServerPropsOnly = { export type FolderListViewServerProps = FolderListViewClientProps & FolderListViewServerPropsOnly export type FolderListViewClientProps = { + activeCollectionFolderSlugs?: SanitizedCollectionConfig['slug'][] + allCollectionFolderSlugs: SanitizedCollectionConfig['slug'][] + allowCreateCollectionSlugs: SanitizedCollectionConfig['slug'][] + baseFolderPath: `/${string}` beforeActions?: React.ReactNode[] - collectionSlug: SanitizedCollectionConfig['slug'] + breadcrumbs: FolderBreadcrumb[] + collectionSlug?: SanitizedCollectionConfig['slug'] disableBulkDelete?: boolean disableBulkEdit?: boolean + documents: FolderOrDocument[] enableRowSelections?: boolean - hasCreatePermission: boolean - newDocumentURL: string + folderFieldName: string + folderID: null | number | string + FolderResultsComponent: React.ReactNode + search?: string + sort?: FolderSortKeys + subfolders: FolderOrDocument[] viewPreference: 'grid' | 'list' } & FolderListViewSlots diff --git a/packages/payload/src/folders/types.ts b/packages/payload/src/folders/types.ts index 579836d2f..3b7b23793 100644 --- a/packages/payload/src/folders/types.ts +++ b/packages/payload/src/folders/types.ts @@ -113,3 +113,10 @@ export type CollectionFoldersConfiguration = { */ browseByFolder?: boolean } + +type BaseFolderSortKeys = keyof Pick< + FolderOrDocument['value'], + '_folderOrDocumentTitle' | 'createdAt' | 'updatedAt' +> + +export type FolderSortKeys = `-${BaseFolderSortKeys}` | BaseFolderSortKeys diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index d18fc7688..d9772817c 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1398,6 +1398,7 @@ export type { UploadFieldValidation, UsernameFieldValidation, } from './fields/validations.js' +export type { FolderSortKeys } from './folders/types.js' export { getFolderData } from './folders/utils/getFolderData.js' export { type ClientGlobalConfig, diff --git a/packages/ui/package.json b/packages/ui/package.json index 0ca4171d1..a56ff29ee 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,6 +56,11 @@ "types": "./src/utilities/buildTableState.ts", "default": "./src/utilities/buildTableState.ts" }, + "./utilities/getFolderResultsComponentAndData": { + "import": "./src/utilities/getFolderResultsComponentAndData.tsx", + "types": "./src/utilities/getFolderResultsComponentAndData.tsx", + "default": "./src/utilities/getFolderResultsComponentAndData.tsx" + }, "./utilities/getClientSchemaMap": { "import": "./src/utilities/getClientSchemaMap.ts", "types": "./src/utilities/getClientSchemaMap.ts", diff --git a/packages/ui/src/elements/FolderView/CollectionTypePill/index.tsx b/packages/ui/src/elements/FolderView/CollectionTypePill/index.tsx index 132283658..38a05855b 100644 --- a/packages/ui/src/elements/FolderView/CollectionTypePill/index.tsx +++ b/packages/ui/src/elements/FolderView/CollectionTypePill/index.tsx @@ -13,8 +13,12 @@ import './index.scss' const baseClass = 'collection-type' export function CollectionTypePill() { - const { filterItems, folderCollectionSlug, folderCollectionSlugs, visibleCollectionSlugs } = - useFolder() + const { + activeCollectionFolderSlugs: visibleCollectionSlugs, + allCollectionFolderSlugs: folderCollectionSlugs, + folderCollectionSlug, + refineFolderData, + } = useFolder() const { i18n, t } = useTranslation() const { config, getEntityConfig } = useConfig() @@ -53,8 +57,8 @@ export function CollectionTypePill() { } key="relation-to-selection-popup" - onChange={({ selectedValues }) => { - void filterItems({ relationTo: selectedValues }) + onChange={({ selectedValues: relationTo }) => { + void refineFolderData({ query: { relationTo }, updateURL: true }) }} options={allCollectionOptions} selectedValues={visibleCollectionSlugs} diff --git a/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx b/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx index c30f94c0f..df8b79865 100644 --- a/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx +++ b/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx @@ -1,11 +1,14 @@ import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' +import { useRouter } from 'next/navigation.js' import React from 'react' import { toast } from 'sonner' import { Dots } from '../../../icons/Dots/index.js' import { useConfig } from '../../../providers/Config/index.js' import { useFolder } from '../../../providers/Folders/index.js' +import { useRouteCache } from '../../../providers/RouteCache/index.js' +import { useRouteTransition } from '../../../providers/RouteTransition/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { ConfirmationModal } from '../../ConfirmationModal/index.js' import { useDocumentDrawer } from '../../DocumentDrawer/index.js' @@ -22,6 +25,8 @@ type Props = { className?: string } export function CurrentFolderActions({ className }: Props) { + const router = useRouter() + const { startRouteTransition } = useRouteTransition() const { breadcrumbs, currentFolder, @@ -29,15 +34,15 @@ export function CurrentFolderActions({ className }: Props) { folderCollectionSlug, folderFieldName, folderID, + getFolderRoute, moveToFolder, - renameFolder, - setFolderID, } = useFolder() const [FolderDocumentDrawer, , { closeDrawer: closeFolderDrawer, openDrawer: openFolderDrawer }] = useDocumentDrawer({ id: folderID, collectionSlug: folderCollectionSlug, }) + const { clearRouteCache } = useRouteCache() const { config } = useConfig() const { routes, serverURL } = config const { closeModal, openModal } = useModal() @@ -48,8 +53,19 @@ export function CurrentFolderActions({ className }: Props) { credentials: 'include', method: 'DELETE', }) - await setFolderID({ folderID: breadcrumbs[breadcrumbs.length - 2]?.id || null }) - }, [breadcrumbs, folderCollectionSlug, folderID, routes.api, serverURL, setFolderID]) + startRouteTransition(() => { + router.push(getFolderRoute(breadcrumbs[breadcrumbs.length - 2]?.id || null)) + }) + }, [ + breadcrumbs, + folderCollectionSlug, + folderID, + getFolderRoute, + router, + serverURL, + routes.api, + startRouteTransition, + ]) if (!folderID) { return null @@ -143,12 +159,9 @@ export function CurrentFolderActions({ className }: Props) { /> { - renameFolder({ - folderID: result.doc.id, - newName: result.doc[folderCollectionConfig.admin.useAsTitle], - }) + onSave={() => { closeFolderDrawer() + clearRouteCache() }} /> diff --git a/packages/ui/src/elements/FolderView/Drawers/EditFolderAction/index.tsx b/packages/ui/src/elements/FolderView/Drawers/EditFolderAction/index.tsx index 52deb969f..957a9b61d 100644 --- a/packages/ui/src/elements/FolderView/Drawers/EditFolderAction/index.tsx +++ b/packages/ui/src/elements/FolderView/Drawers/EditFolderAction/index.tsx @@ -1,5 +1,4 @@ -import type { DocumentDrawerContextProps } from '../../../DocumentDrawer/Provider.js' - +import { useRouteCache } from '../../../../providers/RouteCache/index.js' import { useTranslation } from '../../../../providers/Translation/index.js' import { useDocumentDrawer } from '../../../DocumentDrawer/index.js' import { ListSelectionButton } from '../../../ListSelection/index.js' @@ -7,9 +6,9 @@ import { ListSelectionButton } from '../../../ListSelection/index.js' type EditFolderActionProps = { folderCollectionSlug: string id: number | string - onSave: DocumentDrawerContextProps['onSave'] } -export const EditFolderAction = ({ id, folderCollectionSlug, onSave }: EditFolderActionProps) => { +export const EditFolderAction = ({ id, folderCollectionSlug }: EditFolderActionProps) => { + const { clearRouteCache } = useRouteCache() const { t } = useTranslation() const [FolderDocumentDrawer, , { closeDrawer, openDrawer }] = useDocumentDrawer({ id, @@ -27,9 +26,9 @@ export const EditFolderAction = ({ id, folderCollectionSlug, onSave }: EditFolde { - await onSave(args) + onSave={() => { closeDrawer() + clearRouteCache() }} /> diff --git a/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.tsx b/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.tsx index cc381b6ae..614182936 100644 --- a/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.tsx +++ b/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.tsx @@ -1,20 +1,17 @@ 'use client' import type { CollectionSlug, Document } from 'payload' -import type { - FolderBreadcrumb, - FolderDocumentItemKey, - FolderOrDocument, - GetFolderDataResult, -} from 'payload/shared' +import type { FolderBreadcrumb, FolderOrDocument } from 'payload/shared' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { formatFolderOrDocumentItem } from 'payload/shared' +import { extractID } from 'payload/shared' import React from 'react' -import { useConfig } from '../../../../providers/Config/index.js' +import { useAuth } from '../../../../providers/Auth/index.js' import { FolderProvider, useFolder } from '../../../../providers/Folders/index.js' +import { useRouteCache } from '../../../../providers/RouteCache/index.js' +import { useServerFunctions } from '../../../../providers/ServerFunctions/index.js' import { useTranslation } from '../../../../providers/Translation/index.js' import { Button } from '../../../Button/index.js' import { ConfirmationModal } from '../../../ConfirmationModal/index.js' @@ -28,7 +25,6 @@ import { NoListResults } from '../../../NoListResults/index.js' import { Translation } from '../../../Translation/index.js' import { FolderBreadcrumbs } from '../../Breadcrumbs/index.js' import { ColoredFolderIcon } from '../../ColoredFolderIcon/index.js' -import { ItemCardGrid } from '../../ItemCardGrid/index.js' import './index.scss' const baseClass = 'move-folder-drawer' @@ -59,6 +55,7 @@ export type MoveToFolderDrawerProps = { id: null | number | string name: null | string }) => Promise | void + readonly populateMoveToFolderDrawer?: (folderID: null | number | string) => Promise | void /** * Set to `true` to skip the confirmation modal * @default false @@ -75,51 +72,49 @@ export function MoveItemsToFolderDrawer(props: MoveToFolderDrawerProps) { } function LoadFolderData(props: MoveToFolderDrawerProps) { - const { - config: { routes, serverURL }, - } = useConfig() + const { permissions } = useAuth() const [subfolders, setSubfolders] = React.useState([]) const [documents, setDocuments] = React.useState([]) const [breadcrumbs, setBreadcrumbs] = React.useState([]) + const [FolderResultsComponent, setFolderResultsComponent] = React.useState(null) const [hasLoaded, setHasLoaded] = React.useState(false) + const [folderID, setFolderID] = React.useState(props.fromFolderID || null) + const hasLoadedRef = React.useRef(false) + const { getFolderResultsComponentAndData } = useServerFunctions() - React.useEffect(() => { - const onLoad = async () => { - // call some endpoint to load the data - + const populateMoveToFolderDrawer = React.useCallback( + async (folderIDToPopulate: null | number | string) => { try { - const folderDataReq = await fetch( - `${serverURL}${routes.api}/${props.folderCollectionSlug}/populate-folder-data${props.fromFolderID ? `?folderID=${props.fromFolderID}` : ''}`, - { - credentials: 'include', - headers: { - 'content-type': 'application/json', - }, - }, - ) + const result = await getFolderResultsComponentAndData({ + activeCollectionSlugs: [props.folderCollectionSlug], + browseByFolder: false, + displayAs: 'grid', + folderID: folderIDToPopulate, + sort: '_folderOrDocumentTitle', + }) - if (folderDataReq.status === 200) { - const folderDataRes: GetFolderDataResult = await folderDataReq.json() - setBreadcrumbs(folderDataRes?.breadcrumbs || []) - setSubfolders(folderDataRes?.subfolders || []) - setDocuments(folderDataRes?.documents || []) - } else { - setBreadcrumbs([]) - setSubfolders([]) - setDocuments([]) - } + setBreadcrumbs(result.breadcrumbs || []) + setSubfolders(result?.subfolders || []) + setDocuments(result?.documents || []) + setFolderResultsComponent(result.FolderResultsComponent || null) + setFolderID(folderIDToPopulate) + setHasLoaded(true) } catch (e) { - // eslint-disable-next-line no-console - console.error(e) + setBreadcrumbs([]) + setSubfolders([]) + setDocuments([]) } - setHasLoaded(true) - } + hasLoadedRef.current = true + }, + [getFolderResultsComponentAndData, props.folderCollectionSlug], + ) - if (!hasLoaded) { - void onLoad() + React.useEffect(() => { + if (!hasLoadedRef.current) { + void populateMoveToFolderDrawer(props.fromFolderID) } - }, [props.folderCollectionSlug, routes.api, serverURL, hasLoaded, props.fromFolderID]) + }, [populateMoveToFolderDrawer, props.fromFolderID]) if (!hasLoaded) { return @@ -127,46 +122,58 @@ function LoadFolderData(props: MoveToFolderDrawerProps) { return ( { + await populateMoveToFolderDrawer(item.value.id) + }} subfolders={subfolders} > - + ) } function Content({ drawerSlug, + fromFolderID, fromFolderName, itemsToMove, onConfirm, + populateMoveToFolderDrawer, skipConfirmModal, ...props }: MoveToFolderDrawerProps) { - const { closeModal, openModal } = useModal() + const { clearRouteCache } = useRouteCache() + const { closeModal, isModalOpen, openModal } = useModal() const [count] = React.useState(() => itemsToMove.length) + const [folderAddedToUnderlyingFolder, setFolderAddedToUnderlyingFolder] = React.useState(false) const { i18n, t } = useTranslation() const { - addItems, breadcrumbs, folderCollectionConfig, folderCollectionSlug, folderFieldName, folderID, + FolderResultsComponent, getSelectedItems, - setFolderID, subfolders, } = useFolder() const [FolderDocumentDrawer, , { closeDrawer: closeFolderDrawer, openDrawer: openFolderDrawer }] = useDocumentDrawer({ collectionSlug: folderCollectionSlug, }) - const { getEntityConfig } = useConfig() const getSelectedFolder = React.useCallback((): { id: null | number | string @@ -191,19 +198,19 @@ function Content({ }, [breadcrumbs, getSelectedItems]) const onCreateSuccess = React.useCallback( - ({ collectionSlug, doc }: { collectionSlug: CollectionSlug; doc: Document }) => { - const collectionConfig = getEntityConfig({ collectionSlug }) - void addItems([ - formatFolderOrDocumentItem({ - folderFieldName, - isUpload: Boolean(collectionConfig?.upload), - relationTo: collectionSlug, - useAsTitle: collectionConfig.admin.useAsTitle, - value: doc, - }), - ]) + async ({ collectionSlug, doc }: { collectionSlug: CollectionSlug; doc: Document }) => { + await populateMoveToFolderDrawer(folderID) + if ( + collectionSlug === folderCollectionSlug && + ((doc?.folder && fromFolderID === extractID(doc?.folder)) || + (!fromFolderID && !doc?.folder)) + ) { + // if the folder we created is in the same folder as the one we are moving from + // set variable so we can clear the route cache when we close the drawer + setFolderAddedToUnderlyingFolder(true) + } }, - [addItems, folderFieldName, getEntityConfig], + [populateMoveToFolderDrawer, folderID, fromFolderID, folderCollectionSlug], ) const onConfirmMove = React.useCallback(() => { @@ -212,6 +219,15 @@ function Content({ } }, [getSelectedFolder, onConfirm]) + React.useEffect(() => { + if (!isModalOpen(drawerSlug) && folderAddedToUnderlyingFolder) { + // if we added a folder to the underlying folder, clear the route cache + // so that the folder view will be reloaded with the new folder + setFolderAddedToUnderlyingFolder(false) + clearRouteCache() + } + }, [drawerSlug, isModalOpen, clearRouteCache, folderAddedToUnderlyingFolder]) + return ( <> } @@ -249,7 +265,7 @@ function Content({ ), onClick: breadcrumbs.length ? () => { - void setFolderID({ folderID: null }) + void populateMoveToFolderDrawer(null) } : undefined, }, @@ -259,7 +275,7 @@ function Content({ onClick: index !== breadcrumbs.length - 1 ? () => { - void setFolderID({ folderID: crumb.id }) + void populateMoveToFolderDrawer(crumb.id) } : undefined, })), @@ -284,12 +300,10 @@ function Content({ [folderFieldName]: folderID, }} onSave={(result) => { - if (typeof onCreateSuccess === 'function') { - void onCreateSuccess({ - collectionSlug: folderCollectionConfig.slug, - doc: result.doc, - }) - } + void onCreateSuccess({ + collectionSlug: folderCollectionConfig.slug, + doc: result.doc, + }) closeFolderDrawer() }} redirectAfterCreate={false} @@ -300,14 +314,7 @@ function Content({ {subfolders.length > 0 ? ( - itemKey))} - items={subfolders} - selectedItemKeys={ - new Set([`${folderCollectionSlug}-${getSelectedFolder().id}`]) - } - type="folder" - /> + FolderResultsComponent ) : ( ) } + +type ContextCardProps = { + readonly className?: string + readonly index: number // todo: possibly remove + readonly item: FolderOrDocument + readonly type: 'file' | 'folder' +} +export function ContextFolderFileCard({ type, className, index, item }: ContextCardProps) { + const { + focusedRowIndex, + isDragging, + itemKeysToMove, + onItemClick, + onItemKeyPress, + selectedItemKeys, + } = useFolder() + const isSelected = selectedItemKeys.has(item.itemKey) + + return ( + { + void onItemClick({ event, index, item }) + }} + onKeyDown={(event) => { + void onItemKeyPress({ event, index, item }) + }} + previewUrl={item.value.url} + title={item.value._folderOrDocumentTitle} + type={type} + /> + ) +} diff --git a/packages/ui/src/elements/FolderView/FolderFileTable/index.tsx b/packages/ui/src/elements/FolderView/FolderFileTable/index.tsx index 3a0536fcb..4c3aaf61d 100644 --- a/packages/ui/src/elements/FolderView/FolderFileTable/index.tsx +++ b/packages/ui/src/elements/FolderView/FolderFileTable/index.tsx @@ -1,14 +1,12 @@ -import type { I18nClient } from '@payloadcms/translations' -import type { FolderDocumentItemKey, FolderOrDocument } from 'payload/shared' +'use client' import { getTranslation } from '@payloadcms/translations' import { extractID } from 'payload/shared' import React from 'react' -import type { FormatDateArgs } from '../../../utilities/formatDocTitle/formatDateTitle.js' - import { DocumentIcon } from '../../../icons/Document/index.js' import { useConfig } from '../../../providers/Config/index.js' +import { useFolder } from '../../../providers/Folders/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { formatDate } from '../../../utilities/formatDocTitle/formatDateTitle.js' import { ColoredFolderIcon } from '../ColoredFolderIcon/index.js' @@ -19,42 +17,21 @@ import './index.scss' const baseClass = 'folder-file-table' type Props = { - dateFormat: FormatDateArgs['pattern'] - disabledItems?: Set - documents: FolderOrDocument[] - focusedRowIndex: number - i18n: I18nClient - isMovingItems: boolean - onRowClick: (args: { - event: React.MouseEvent - index: number - item: FolderOrDocument - }) => void - onRowPress: (args: { - event: React.KeyboardEvent - index: number - item: FolderOrDocument - }) => void - selectedItems: Set showRelationCell?: boolean - subfolders: FolderOrDocument[] } -export function FolderFileTable({ - dateFormat, - disabledItems = new Set(), - documents, - focusedRowIndex, - i18n, - isMovingItems, - onRowClick, - onRowPress, - selectedItems, - showRelationCell = true, - subfolders, -}: Props) { +export function FolderFileTable({ showRelationCell = true }: Props) { + const { + documents, + focusedRowIndex, + isDragging, + onItemClick, + onItemKeyPress, + selectedItemKeys, + subfolders, + } = useFolder() const { config } = useConfig() - const { t } = useTranslation() + const { i18n, t } = useTranslation() const [relationToMap] = React.useState(() => { const map: Record = {} @@ -109,7 +86,11 @@ export function FolderFileTable({ } if ((name === 'createdAt' || name === 'updatedAt') && value[name]) { - cellValue = formatDate({ date: value[name], i18n, pattern: dateFormat }) + cellValue = formatDate({ + date: value[name], + i18n, + pattern: config.admin.dateFormat, + }) } if (name === 'type') { @@ -127,9 +108,7 @@ export function FolderFileTable({ return cellValue } })} - disabled={ - (isMovingItems && selectedItems?.has(itemKey)) || disabledItems?.has(itemKey) - } + disabled={isDragging && selectedItemKeys?.has(itemKey)} dragData={{ id: subfolderID, type: 'folder', @@ -137,19 +116,19 @@ export function FolderFileTable({ id={subfolderID} isDroppable isFocused={focusedRowIndex === rowIndex} - isSelected={selectedItems.has(itemKey)} - isSelecting={selectedItems.size > 0} + isSelected={selectedItemKeys.has(itemKey)} + isSelecting={selectedItemKeys.size > 0} itemKey={itemKey} key={`${rowIndex}-${itemKey}`} onClick={(event) => { - void onRowClick({ + void onItemClick({ event, index: rowIndex, item: subfolder, }) }} onKeyDown={(event) => { - void onRowPress({ + void onItemKeyPress({ event, index: rowIndex, item: subfolder, @@ -173,7 +152,11 @@ export function FolderFileTable({ } if ((name === 'createdAt' || name === 'updatedAt') && value[name]) { - cellValue = formatDate({ date: value[name], i18n, pattern: dateFormat }) + cellValue = formatDate({ + date: value[name], + i18n, + pattern: config.admin.dateFormat, + }) } if (name === 'type') { @@ -191,26 +174,26 @@ export function FolderFileTable({ return cellValue } })} - disabled={isMovingItems || disabledItems?.has(itemKey)} + disabled={isDragging || selectedItemKeys?.has(itemKey)} dragData={{ id: documentID, type: 'document', }} id={documentID} isFocused={focusedRowIndex === rowIndex} - isSelected={selectedItems.has(itemKey)} - isSelecting={selectedItems.size > 0} + isSelected={selectedItemKeys.has(itemKey)} + isSelecting={selectedItemKeys.size > 0} itemKey={itemKey} key={`${rowIndex}-${itemKey}`} onClick={(event) => { - void onRowClick({ + void onItemClick({ event, index: rowIndex, item: document, }) }} onKeyDown={(event) => { - void onRowPress({ + void onItemKeyPress({ event, index: rowIndex, item: document, diff --git a/packages/ui/src/elements/FolderView/ItemCardGrid/index.tsx b/packages/ui/src/elements/FolderView/ItemCardGrid/index.tsx index 1a3f1bca2..0de8c9c9c 100644 --- a/packages/ui/src/elements/FolderView/ItemCardGrid/index.tsx +++ b/packages/ui/src/elements/FolderView/ItemCardGrid/index.tsx @@ -1,20 +1,16 @@ 'use client' -import type { FolderDocumentItemKey, FolderOrDocument } from 'payload/shared' +import type { FolderOrDocument } from 'payload/shared' import React from 'react' -import { useFolder } from '../../../providers/Folders/index.js' -import { FolderFileCard } from '../FolderFileCard/index.js' +import { ContextFolderFileCard } from '../FolderFileCard/index.js' import './index.scss' const baseClass = 'item-card-grid' type ItemCardGridProps = { - disabledItemKeys?: Set items: FolderOrDocument[] - RenderActionGroup?: (args: { index: number; item: FolderOrDocument }) => React.ReactNode - selectedItemKeys: Set title?: string } & ( | { @@ -26,17 +22,7 @@ type ItemCardGridProps = { type: 'folder' } ) -export function ItemCardGrid({ - type, - disabledItemKeys, - items, - RenderActionGroup, - selectedItemKeys, - subfolderCount, - title, -}: ItemCardGridProps) { - const { focusedRowIndex, isDragging, onItemClick, onItemKeyPress, selectedIndexes } = useFolder() - +export function ItemCardGrid({ type, items, subfolderCount, title }: ItemCardGridProps) { return ( <> {title &&

{title}

} @@ -45,38 +31,9 @@ export function ItemCardGrid({ ? null : items.map((item, _index) => { const index = _index + (subfolderCount || 0) - const { itemKey, value } = item + const { itemKey } = item - return ( - { - void onItemClick({ event, index, item }) - }} - onKeyDown={(event) => { - void onItemKeyPress({ event, index, item }) - }} - PopupActions={ - RenderActionGroup - ? RenderActionGroup({ - index, - item, - }) - : null - } - previewUrl={value?.url} - selectedCount={selectedIndexes.size} - title={value._folderOrDocumentTitle} - type={type} - /> - ) + return })} diff --git a/packages/ui/src/elements/FolderView/SortByPill/index.tsx b/packages/ui/src/elements/FolderView/SortByPill/index.tsx index 197b919c8..f325ab974 100644 --- a/packages/ui/src/elements/FolderView/SortByPill/index.tsx +++ b/packages/ui/src/elements/FolderView/SortByPill/index.tsx @@ -45,9 +45,12 @@ const orderOnOptions: { }, ] export function SortByPill() { - const { documents, sortAndUpdateState, sortDirection, sortOn, subfolders } = useFolder() + const { refineFolderData, sort } = useFolder() const { t } = useTranslation() - const [selectedSortOption] = sortOnOptions.filter(({ value }) => value === sortOn) + const sortDirection = sort.startsWith('-') ? 'desc' : 'asc' + const [selectedSortOption] = sortOnOptions.filter( + ({ value }) => value === (sort.startsWith('-') ? sort.slice(1) : sort), + ) const [selectedOrderOption] = orderOnOptions.filter(({ value }) => value === sortDirection) return ( @@ -73,13 +76,11 @@ export function SortByPill() { active={selectedSortOption.value === value} key={value} onClick={() => { - sortAndUpdateState({ - documentsToSort: documents, - sortOn: value as keyof Pick< - FolderOrDocument['value'], - '_folderOrDocumentTitle' | 'createdAt' | 'updatedAt' - >, - subfoldersToSort: subfolders, + refineFolderData({ + query: { + sort: value, + }, + updateURL: true, }) close() }} @@ -97,11 +98,14 @@ export function SortByPill() { className={`${baseClass}__order-option`} key={value} onClick={() => { - sortAndUpdateState({ - documentsToSort: documents, - sortDirection: value, - subfoldersToSort: subfolders, - }) + if (value === 'asc') { + refineFolderData({ + query: { + sort: value === 'asc' ? `-${sort}` : sort, + }, + updateURL: true, + }) + } close() }} > diff --git a/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx b/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx index fdb99966a..ff8fe24c9 100644 --- a/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx +++ b/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx @@ -118,12 +118,12 @@ export function ListCreateNewDocInFolderButton({ initialData={{ [folderFieldName]: folderID, }} - onSave={({ doc }) => { - closeModal(newDocInFolderDrawerSlug) - void onCreateSuccess({ + onSave={async ({ doc }) => { + await onCreateSuccess({ collectionSlug: createCollectionSlug, doc, }) + closeModal(newDocInFolderDrawerSlug) }} redirectAfterCreate={false} /> @@ -134,13 +134,11 @@ export function ListCreateNewDocInFolderButton({ initialData={{ [folderFieldName]: folderID, }} - onSave={(result) => { - if (typeof onCreateSuccess === 'function') { - void onCreateSuccess({ - collectionSlug: folderCollectionConfig.slug, - doc: result.doc, - }) - } + onSave={async (result) => { + await onCreateSuccess({ + collectionSlug: folderCollectionConfig.slug, + doc: result.doc, + }) closeFolderDrawer() }} redirectAfterCreate={false} diff --git a/packages/ui/src/exports/rsc/index.ts b/packages/ui/src/exports/rsc/index.ts index 48c2f9796..53655247e 100644 --- a/packages/ui/src/exports/rsc/index.ts +++ b/packages/ui/src/exports/rsc/index.ts @@ -4,6 +4,7 @@ export { FolderEditField } from '../../elements/FolderView/Field/index.server.js export { File } from '../../graphics/File/index.js' export { CheckIcon } from '../../icons/Check/index.js' export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js' +export { getFolderResultsComponentAndData } from '../../utilities/getFolderResultsComponentAndData.js' export { renderFilters, renderTable } from '../../utilities/renderTable.js' export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js' export { upsertPreferences } from '../../utilities/upsertPreferences.js' diff --git a/packages/ui/src/fields/Relationship/Input.tsx b/packages/ui/src/fields/Relationship/Input.tsx index 8e7d75731..13242f6fc 100644 --- a/packages/ui/src/fields/Relationship/Input.tsx +++ b/packages/ui/src/fields/Relationship/Input.tsx @@ -101,7 +101,8 @@ export const RelationshipInput: React.FC = (props) => { const valueRef = useRef(value) // the line below seems odd - + + // eslint-disable-next-line react-compiler/react-compiler valueRef.current = value const [DocumentDrawer, , { isDrawerOpen, openDrawer }] = useDocumentDrawer({ diff --git a/packages/ui/src/providers/Folders/index.tsx b/packages/ui/src/providers/Folders/index.tsx index 9355c6357..62d47883e 100644 --- a/packages/ui/src/providers/Folders/index.tsx +++ b/packages/ui/src/providers/Folders/index.tsx @@ -1,24 +1,28 @@ 'use client' -import type { ClientCollectionConfig, CollectionSlug, Document } from 'payload' -import type { FolderBreadcrumb, FolderOrDocument, GetFolderDataResult } from 'payload/shared' +import type { ClientCollectionConfig, CollectionSlug, FolderSortKeys } from 'payload' +import type { FolderBreadcrumb, FolderDocumentItemKey, FolderOrDocument } from 'payload/shared' -import { useRouter } from 'next/navigation.js' +import { useRouter, useSearchParams } from 'next/navigation.js' import { extractID, formatAdminURL, formatFolderOrDocumentItem } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' import { toast } from 'sonner' import { useDrawerDepth } from '../../elements/Drawer/index.js' +import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { useConfig } from '../Config/index.js' import { useRouteTransition } from '../RouteTransition/index.js' import { useTranslation } from '../Translation/index.js' import { getMetaSelection, getShiftSelection, groupItemIDsByRelation } from './selection.js' -type SortKeys = keyof Pick< - FolderOrDocument['value'], - '_folderOrDocumentTitle' | 'createdAt' | 'updatedAt' -> -type SortDirection = 'asc' | 'desc' + +type FolderQueryParams = { + page?: string + relationTo?: CollectionSlug[] + search?: string + sort?: string +} + export type FileCardData = { filename: string id: number | string @@ -28,120 +32,106 @@ export type FileCardData = { } export type FolderContextValue = { - addItems: (args: FolderOrDocument[]) => void + /** + * The collection slugs that a view can be filtered by + * Used in the browse-by-folder view + */ + activeCollectionFolderSlugs: CollectionSlug[] + /** + * Folder enabled collection slugs that can be populated within the provider + */ + readonly allCollectionFolderSlugs?: CollectionSlug[] + allowCreateCollectionSlugs: CollectionSlug[] breadcrumbs?: FolderBreadcrumb[] clearSelections: () => void currentFolder?: FolderOrDocument | null documents?: FolderOrDocument[] - filterItems: (args: { relationTo?: CollectionSlug[]; search?: string }) => Promise focusedRowIndex: number folderCollectionConfig: ClientCollectionConfig folderCollectionSlug: string - /** - * Folder enabled collection slugs that can be populated within the provider - */ - readonly folderCollectionSlugs?: CollectionSlug[] folderFieldName: string folderID?: number | string + FolderResultsComponent: React.ReactNode + getFolderRoute: (toFolderID?: number | string) => string getSelectedItems?: () => FolderOrDocument[] isDragging: boolean + itemKeysToMove?: Set lastSelectedIndex: null | number moveToFolder: (args: { itemsToMove: FolderOrDocument[] toFolderID?: number | string }) => Promise - onItemClick: (args: { - event: React.MouseEvent - index: number - item: FolderOrDocument - }) => Promise | void + onItemClick: (args: { event: React.MouseEvent; index: number; item: FolderOrDocument }) => void onItemKeyPress: (args: { event: React.KeyboardEvent index: number item: FolderOrDocument - }) => Promise | void - removeItems: (args: FolderOrDocument[]) => void - renameFolder: (args: { folderID: number | string; newName: string }) => void + }) => void + refineFolderData: (args: { query?: FolderQueryParams; updateURL: boolean }) => void search: string - selectedIndexes: Set + readonly selectedItemKeys: Set setBreadcrumbs: React.Dispatch> setFocusedRowIndex: React.Dispatch> - setFolderID: (args: { folderID: number | string }) => Promise | void setIsDragging: React.Dispatch> - sortAndUpdateState: (args: { - documentsToSort?: FolderOrDocument[] - sortDirection?: 'asc' | 'desc' - sortOn?: SortKeys - subfoldersToSort?: FolderOrDocument[] - }) => void - sortDirection: SortDirection - sortOn: SortKeys + sort: FolderSortKeys subfolders?: FolderOrDocument[] - visibleCollectionSlugs: CollectionSlug[] } const Context = React.createContext({ - addItems: () => {}, + activeCollectionFolderSlugs: [], + allCollectionFolderSlugs: [], + allowCreateCollectionSlugs: [], breadcrumbs: [], clearSelections: () => {}, currentFolder: null, documents: [], - filterItems: () => undefined, focusedRowIndex: -1, folderCollectionConfig: null, folderCollectionSlug: '', - folderCollectionSlugs: [], folderFieldName: 'folder', folderID: undefined, + FolderResultsComponent: null, + getFolderRoute: () => '', getSelectedItems: () => [], isDragging: false, + itemKeysToMove: undefined, lastSelectedIndex: null, moveToFolder: () => Promise.resolve(undefined), onItemClick: () => undefined, onItemKeyPress: () => undefined, - removeItems: () => undefined, - renameFolder: () => undefined, + refineFolderData: () => undefined, search: '', - selectedIndexes: new Set(), + selectedItemKeys: new Set(), setBreadcrumbs: () => {}, setFocusedRowIndex: () => -1, - setFolderID: () => null, setIsDragging: () => false, - sortAndUpdateState: () => undefined, - sortDirection: 'asc', - sortOn: '_folderOrDocumentTitle', + sort: '_folderOrDocumentTitle', subfolders: [], - visibleCollectionSlugs: [], }) -function filterOutItems({ - items, - relationTo, - search, -}: { - items: FolderOrDocument[] - relationTo?: CollectionSlug[] - search?: string -}) { - if (typeof search !== 'string' && relationTo === undefined) { - return items - } - - const searchLower = (search || '').toLowerCase() - - return items.filter((item) => { - const itemTitle = item.value._folderOrDocumentTitle.toLowerCase() - const itemRelationTo = item.relationTo - - return ( - (relationTo ? relationTo.includes(itemRelationTo) : true) && - (!searchLower || itemTitle.includes(searchLower)) - ) - }) -} - export type FolderProviderProps = { + /** + * The collection slugs that are being viewed + */ + readonly activeCollectionFolderSlugs?: CollectionSlug[] + /** + * Folder enabled collection slugs that can be populated within the provider + */ + readonly allCollectionFolderSlugs: CollectionSlug[] + /** + * Array of slugs that can be created in the folder view + */ + readonly allowCreateCollectionSlugs: CollectionSlug[] readonly allowMultiSelection?: boolean + /** + * The base folder route path + * + * @example + * `/collections/:collectionSlug/:folderCollectionSlug` + * or + * `/browse-by-folder` + */ + readonly baseFolderPath?: `/${string}` /** * Breadcrumbs for the current folder */ @@ -150,22 +140,10 @@ export type FolderProviderProps = { * Children to render inside the provider */ readonly children: React.ReactNode - /** - * Required for collection-folder views - */ - readonly collectionSlug?: CollectionSlug /** * All documents in the current folder */ readonly documents: FolderOrDocument[] - /** - * The collection slugs that are being viewed - */ - readonly filteredCollectionSlugs?: CollectionSlug[] - /** - * Folder enabled collection slugs that can be populated within the provider - */ - readonly folderCollectionSlugs: CollectionSlug[] /** * The name of the field that contains the folder relation */ @@ -174,6 +152,14 @@ export type FolderProviderProps = { * The ID of the current folder */ readonly folderID?: number | string + /** + * The component to render the folder results + */ + readonly FolderResultsComponent: React.ReactNode + /** + * Optional function to call when an item is clicked + */ + readonly onItemClick?: (itme: FolderOrDocument) => void /** * The intial search query */ @@ -185,34 +171,40 @@ export type FolderProviderProps = { * `name` for descending * `-name` for ascending */ - readonly sort?: `-${SortKeys}` | SortKeys + readonly sort?: FolderSortKeys /** * All subfolders in the current folder */ readonly subfolders: FolderOrDocument[] } export function FolderProvider({ + activeCollectionFolderSlugs: activeCollectionSlugs, + allCollectionFolderSlugs = [], + allowCreateCollectionSlugs, allowMultiSelection = true, + baseFolderPath, breadcrumbs: _breadcrumbsFromProps = [], children, - collectionSlug, - documents: allDocumentsFromProps = [], - filteredCollectionSlugs, - folderCollectionSlugs = [], + documents, folderFieldName, - folderID: _folderIDFromProps = undefined, - search: _searchFromProps, - sort, - subfolders: subfoldersFromProps = [], + folderID, + FolderResultsComponent: InitialFolderResultsComponent, + onItemClick: onItemClickFromProps, + search, + sort = '_folderOrDocumentTitle', + subfolders, }: FolderProviderProps) { const parentFolderContext = useFolder() - const { config, getEntityConfig } = useConfig() + const { config } = useConfig() const { routes, serverURL } = config const drawerDepth = useDrawerDepth() const { t } = useTranslation() const router = useRouter() const { startRouteTransition } = useRouteTransition() + const [FolderResultsComponent, setFolderResultsComponent] = React.useState( + InitialFolderResultsComponent || (() => null), + ) const [folderCollectionConfig] = React.useState(() => config.collections.find( (collection) => config.folders && collection.slug === config.folders.slug, @@ -220,206 +212,135 @@ export function FolderProvider({ ) const folderCollectionSlug = folderCollectionConfig.slug + const rawSearchParams = useSearchParams() + const searchParams = React.useMemo(() => parseSearchParams(rawSearchParams), [rawSearchParams]) + const [currentQuery, setCurrentQuery] = React.useState(searchParams) + const [isDragging, setIsDragging] = React.useState(false) - const [selectedIndexes, setSelectedIndexes] = React.useState>(() => new Set()) + const [selectedItemKeys, setSelectedItemKeys] = React.useState>( + () => new Set(), + ) const [focusedRowIndex, setFocusedRowIndex] = React.useState(-1) const [lastSelectedIndex, setLastSelectedIndex] = React.useState(null) - const [visibleCollectionSlugs, setVisibleCollectionSlugs] = React.useState( - filteredCollectionSlugs || [...folderCollectionSlugs, folderCollectionSlug], - ) - const [activeFolderID, setActiveFolderID] = - React.useState(_folderIDFromProps) const [breadcrumbs, setBreadcrumbs] = React.useState(_breadcrumbsFromProps) - const [allSubfolders, setAllSubfolders] = React.useState(subfoldersFromProps) - const [subfolders, setSubfolders] = React.useState(() => { - return filterOutItems({ - items: allSubfolders, - relationTo: visibleCollectionSlugs.includes(folderCollectionSlug) - ? [folderCollectionSlug] - : [], - search: _searchFromProps, - }) - }) - const [allDocuments, setAllDocuments] = React.useState(allDocumentsFromProps) - const [documents, setDocuments] = React.useState(() => - filterOutItems({ - items: allDocuments, - relationTo: visibleCollectionSlugs, - search: _searchFromProps, - }), - ) - const [search, setSearch] = React.useState(_searchFromProps) - const [baseFolderPath] = React.useState<`/${string}`>(() => { - if (collectionSlug) { - return `/collections/${collectionSlug}/${folderCollectionSlug}` - } else { - return config.admin.routes.browseByFolder - } - }) - const [sortDirection, setSortDirection] = React.useState(() => { - if (sort) { - return sort.startsWith('-') ? 'desc' : 'asc' - } - return 'asc' - }) - const [sortOn, setSortOn] = React.useState(() => { - if (sort) { - return sort.replace(/^-/, '') as SortKeys - } - return '_folderOrDocumentTitle' - }) - const lastClickTime = React.useRef(null) const totalCount = subfolders.length + documents.length - const formatFolderURL = React.useCallback( - (args: { - folderID?: number | string - relationTo?: string[] - search?: string - sortBy?: SortKeys - sortDirection?: SortDirection - }) => { - const newFolderID = 'folderID' in args ? args.folderID : activeFolderID - const params = { - relationTo: - 'relationTo' in args - ? args.relationTo - : collectionSlug - ? undefined - : visibleCollectionSlugs, - search: 'search' in args ? args.search : search, - sortBy: 'sortBy' in args ? args.sortBy : sortOn, - sortDirection: 'sortDirection' in args ? args.sortDirection : sortDirection, - } - - return formatAdminURL({ - adminRoute: config.routes.admin, - path: `${baseFolderPath}${newFolderID ? `/${newFolderID}` : ''}${qs.stringify(params, { - addQueryPrefix: true, - })}`, - }) - }, - [ - activeFolderID, - baseFolderPath, - collectionSlug, - config.routes.admin, - search, - sortDirection, - sortOn, - visibleCollectionSlugs, - ], - ) - const clearSelections = React.useCallback(() => { setFocusedRowIndex(-1) - setSelectedIndexes(new Set()) + setSelectedItemKeys(new Set()) setLastSelectedIndex(undefined) }, []) - /** - * Used to populate drawer data. - * - * This is used when the user navigates to a folder. - */ - const getFolderData = React.useCallback( - async ({ folderID, search: _search = undefined }): Promise => { - // if folderID is not set, we want to search all documents - const searchFilter = !folderID - ? ((typeof _search === 'string' ? _search : search) || '').trim() - : undefined - const query = qs.stringify( - { - collectionSlug, - folderID, - search: searchFilter, - }, - { addQueryPrefix: true }, - ) + const mergeQuery = React.useCallback( + (newQuery: Partial = {}): Partial => { + let page = 'page' in newQuery ? newQuery.page : currentQuery?.page - const folderDataReq = await fetch( - `${serverURL}${routes.api}/${folderCollectionSlug}/populate-folder-data${query}`, - { - credentials: 'include', - headers: { - 'content-type': 'application/json', - }, - }, - ) - - if (folderDataReq.status === 200) { - const folderDataRes: GetFolderDataResult = await folderDataReq.json() - setAllSubfolders(folderDataRes.subfolders || []) - setAllDocuments(folderDataRes.documents || []) - return folderDataRes - } else { - return { - breadcrumbs: [], - documents: [], - subfolders: [], - } + if ('search' in newQuery) { + page = '1' } + + const mergedQuery = { + ...currentQuery, + ...newQuery, + page, + search: 'search' in newQuery ? newQuery.search : currentQuery?.search, + sort: 'sort' in newQuery ? newQuery.sort : (currentQuery?.sort ?? undefined), + } + + return mergedQuery }, - [folderCollectionSlug, search, routes.api, serverURL, collectionSlug], + [currentQuery], ) - const setNewActiveFolderID: FolderContextValue['setFolderID'] = React.useCallback( - async ({ folderID: toFolderID }) => { - clearSelections() - if (drawerDepth === 1) { - // not in a drawer (default is 1) + const refineFolderData: FolderContextValue['refineFolderData'] = React.useCallback( + ({ query, updateURL }) => { + if (updateURL) { + const newQuery = mergeQuery(query) startRouteTransition(() => - router.push( - formatFolderURL({ - folderID: toFolderID, - }), - ), + router.replace(`${qs.stringify(newQuery, { addQueryPrefix: true })}`), ) - } else { - const folderDataRes = await getFolderData({ folderID: toFolderID }) - setBreadcrumbs(folderDataRes?.breadcrumbs || []) - setSubfolders(folderDataRes?.subfolders || []) - setDocuments(folderDataRes?.documents || []) - setActiveFolderID(toFolderID) + + setCurrentQuery(newQuery) } }, - [clearSelections, drawerDepth, startRouteTransition, router, formatFolderURL, getFolderData], + [mergeQuery, router, startRouteTransition], + ) + + const getFolderRoute: FolderContextValue['getFolderRoute'] = React.useCallback( + (toFolderID) => { + const newQuery = mergeQuery({ page: '1', search: '' }) + return formatAdminURL({ + adminRoute: config.routes.admin, + path: `${baseFolderPath}${toFolderID ? `/${toFolderID}` : ''}${qs.stringify(newQuery, { addQueryPrefix: true })}`, + serverURL: config.serverURL, + }) + }, + [baseFolderPath, config.routes.admin, config.serverURL, mergeQuery], + ) + + const getItem = React.useCallback( + (itemKey: FolderDocumentItemKey) => { + return [...subfolders, ...documents].find((doc) => doc.itemKey === itemKey) + }, + [documents, subfolders], ) const getSelectedItems = React.useCallback(() => { - return Array.from(selectedIndexes).reduce((acc, index) => { - const item = [...subfolders, ...documents][index] + return Array.from(selectedItemKeys).reduce((acc, itemKey) => { + const item = getItem(itemKey) if (item) { acc.push(item) } return acc }, []) - }, [documents, selectedIndexes, subfolders]) + }, [selectedItemKeys, getItem]) const navigateAfterSelection = React.useCallback( - async ({ collectionSlug, docID }: { collectionSlug: string; docID: number | string }) => { - if (collectionSlug === folderCollectionSlug) { - await setNewActiveFolderID({ folderID: docID }) - } else if (collectionSlug) { - router.push( - formatAdminURL({ - adminRoute: config.routes.admin, - path: `/collections/${collectionSlug}/${docID}`, - }), - ) + ({ collectionSlug, docID }: { collectionSlug: string; docID?: number | string }) => { + if (drawerDepth === 1) { + // not in a drawer (default is 1) + clearSelections() + if (collectionSlug === folderCollectionSlug) { + // clicked on folder, take the user to the folder view + startRouteTransition(() => router.push(getFolderRoute(docID))) + } else if (collectionSlug) { + // clicked on document, take the user to the documet view + startRouteTransition(() => { + router.push( + formatAdminURL({ + adminRoute: config.routes.admin, + path: `/collections/${collectionSlug}/${docID}`, + }), + ) + }) + } + } + + if (typeof onItemClickFromProps === 'function') { + onItemClickFromProps(getItem(`${collectionSlug}-${docID}`)) } }, - [setNewActiveFolderID, folderCollectionSlug, router, config.routes.admin], + [ + clearSelections, + config.routes.admin, + drawerDepth, + folderCollectionSlug, + getFolderRoute, + getItem, + onItemClickFromProps, + router, + startRouteTransition, + ], ) const onItemKeyPress: FolderContextValue['onItemKeyPress'] = React.useCallback( - async ({ event, index, item }) => { + ({ event, index, item }) => { const { code, ctrlKey, metaKey, shiftKey } = event const isShiftPressed = shiftKey const isCtrlPressed = ctrlKey || metaKey - let newSelectedIndexes: Set = selectedIndexes + let newSelectedIndexes: Set | undefined = undefined switch (code) { case 'ArrowDown': { @@ -463,7 +384,7 @@ export function FolderProvider({ break } case 'Enter': { - if (selectedIndexes.size === 1) { + if (selectedItemKeys.size === 1) { newSelectedIndexes = new Set([]) setFocusedRowIndex(undefined) } @@ -471,7 +392,6 @@ export function FolderProvider({ } case 'Escape': { setFocusedRowIndex(undefined) - setSelectedIndexes(new Set([])) newSelectedIndexes = new Set([]) break } @@ -501,12 +421,12 @@ export function FolderProvider({ case 'Tab': { if (allowMultiSelection && isShiftPressed) { const prevIndex = index - 1 - if (prevIndex < 0 && newSelectedIndexes.size > 0) { + if (prevIndex < 0 && newSelectedIndexes?.size > 0) { setFocusedRowIndex(prevIndex) } } else { const nextIndex = index + 1 - if (nextIndex === totalCount && newSelectedIndexes.size > 0) { + if (nextIndex === totalCount && selectedItemKeys.size > 0) { setFocusedRowIndex(totalCount - 1) } } @@ -514,23 +434,43 @@ export function FolderProvider({ } } - setSelectedIndexes(newSelectedIndexes) - if (selectedIndexes.size === 1 && code === 'Enter') { - await navigateAfterSelection({ + if (!newSelectedIndexes) { + return + } + + setSelectedItemKeys( + [...subfolders, ...documents].reduce((acc, item, index) => { + if (newSelectedIndexes?.size && newSelectedIndexes.has(index)) { + acc.add(item.itemKey) + } + return acc + }, new Set()), + ) + + if (selectedItemKeys.size === 1 && code === 'Enter') { + navigateAfterSelection({ collectionSlug: item.relationTo, docID: extractID(item.value), }) } }, - [allowMultiSelection, lastSelectedIndex, navigateAfterSelection, selectedIndexes, totalCount], + [ + allowMultiSelection, + documents, + lastSelectedIndex, + navigateAfterSelection, + subfolders, + totalCount, + selectedItemKeys, + ], ) const onItemClick: FolderContextValue['onItemClick'] = React.useCallback( - async ({ event, index, item }) => { + ({ event, index, item }) => { let doubleClicked: boolean = false const isCtrlPressed = event.ctrlKey || event.metaKey const isShiftPressed = event.shiftKey - let newSelectedIndexes = new Set(selectedIndexes) + let newSelectedIndexes: Set | undefined = undefined if (allowMultiSelection && isCtrlPressed) { newSelectedIndexes = getMetaSelection({ @@ -544,7 +484,7 @@ export function FolderProvider({ }) } else if (allowMultiSelection && event.type === 'pointermove') { // on drag start of an unselected item - if (!selectedIndexes.has(index)) { + if (!selectedItemKeys.has(item.itemKey)) { newSelectedIndexes = new Set([index]) } setLastSelectedIndex(index) @@ -557,341 +497,44 @@ export function FolderProvider({ setLastSelectedIndex(index) } - if (newSelectedIndexes.size === 0) { + if (!newSelectedIndexes) { setFocusedRowIndex(undefined) } else { setFocusedRowIndex(index) } - setSelectedIndexes(newSelectedIndexes) + if (newSelectedIndexes) { + setSelectedItemKeys( + [...subfolders, ...documents].reduce((acc, item, index) => { + if (newSelectedIndexes.size && newSelectedIndexes.has(index)) { + acc.add(item.itemKey) + } + return acc + }, new Set()), + ) + } + if (doubleClicked) { - await navigateAfterSelection({ + navigateAfterSelection({ collectionSlug: item.relationTo, docID: extractID(item.value), }) } }, - [selectedIndexes, lastSelectedIndex, allowMultiSelection, navigateAfterSelection], - ) - - const filterItems: FolderContextValue['filterItems'] = React.useCallback( - async ({ relationTo: _relationTo, search: _search }) => { - const relationTo = Array.isArray(_relationTo) ? _relationTo : visibleCollectionSlugs - const searchFilter = ((typeof _search === 'string' ? _search : search) || '').trim() - - let filteredDocuments: FolderOrDocument[] = allDocuments - let filteredSubfolders: FolderOrDocument[] = allSubfolders - - if (collectionSlug && !activeFolderID && _search !== search) { - // this allows us to search all documents in the collection when we are not in a folder and in the folder-collection view - const res = await getFolderData({ - folderID: activeFolderID, - search: searchFilter, - }) - filteredDocuments = filterOutItems({ - items: res.documents, - relationTo, - search: searchFilter, - }) - filteredSubfolders = filterOutItems({ - items: res.subfolders, - relationTo: [folderCollectionSlug], - search: searchFilter, - }) - } else { - filteredDocuments = filterOutItems({ - items: allDocuments, - relationTo, - search: searchFilter, - }) - filteredSubfolders = filterOutItems({ - items: allSubfolders, - relationTo: relationTo.includes(folderCollectionSlug) ? [folderCollectionSlug] : [], - search: searchFilter, - }) - } - - setDocuments(filteredDocuments) - setSubfolders(filteredSubfolders) - setSearch(searchFilter) - setVisibleCollectionSlugs(relationTo) - - if (drawerDepth === 1) { - router.replace( - formatFolderURL({ - relationTo, - search: searchFilter || undefined, - }), - ) - } - }, [ - collectionSlug, - visibleCollectionSlugs, - search, - activeFolderID, - allDocuments, - allSubfolders, - folderCollectionSlug, - drawerDepth, - getFolderData, - router, - formatFolderURL, + selectedItemKeys, + allowMultiSelection, + lastSelectedIndex, + subfolders, + documents, + navigateAfterSelection, ], ) - const sortItems = React.useCallback( - ({ - documentsToSort, - sortDirection: sortDirectionArg, - sortOn: sortOnArg, - subfoldersToSort, - }: Parameters[0]): { - newURL?: string - sortedDocuments?: FolderOrDocument[] - sortedSubfolders?: FolderOrDocument[] - } => { - let sortedDocuments: FolderOrDocument[] | undefined - let sortedSubfolders: FolderOrDocument[] | undefined - const sortDirectionToUse = sortDirectionArg || sortDirection - const sortOnToUse = sortOnArg || sortOn - - if (sortOnArg) { - setSortOn(sortOnArg) - } - - if (sortDirectionArg) { - setSortDirection(sortDirectionArg) - } - - const newURL = formatFolderURL({ - sortBy: sortOnArg || sortOn, - sortDirection: sortDirectionArg || sortDirection, - }) - - if (documentsToSort) { - sortedDocuments = [...documentsToSort].sort((a, b) => { - const aValue = a.value[sortOnToUse] - const bValue = b.value[sortOnToUse] - - if (aValue == null && bValue == null) { - return 0 - } - if (aValue == null) { - return sortDirectionToUse === 'asc' ? 1 : -1 - } - if (bValue == null) { - return sortDirectionToUse === 'asc' ? -1 : 1 - } - - if (typeof aValue === 'string' && typeof bValue === 'string') { - return sortDirectionToUse === 'asc' - ? aValue.localeCompare(bValue) - : bValue.localeCompare(aValue) - } - - return 0 - }) - } - - if (subfoldersToSort) { - sortedSubfolders = [...subfoldersToSort].sort((a, b) => { - const aValue = a.value[sortOnToUse] - const bValue = b.value[sortOnToUse] - - if (aValue == null && bValue == null) { - return 0 - } - if (aValue == null) { - return sortDirectionToUse === 'asc' ? 1 : -1 - } - if (bValue == null) { - return sortDirectionToUse === 'asc' ? -1 : 1 - } - - if (typeof aValue === 'string' && typeof bValue === 'string') { - return sortDirectionToUse === 'asc' - ? aValue.localeCompare(bValue) - : bValue.localeCompare(aValue) - } - - return 0 - }) - } - - return { - newURL, - sortedDocuments, - sortedSubfolders, - } - }, - [formatFolderURL, sortDirection, sortOn], - ) - - const sortAndUpdateState: FolderContextValue['sortAndUpdateState'] = React.useCallback( - ({ documentsToSort, sortDirection: sortDirectionArg, sortOn: sortOnArg, subfoldersToSort }) => { - const { newURL, sortedDocuments, sortedSubfolders } = sortItems({ - documentsToSort, - sortDirection: sortDirectionArg, - sortOn: sortOnArg, - subfoldersToSort, - }) - - if (sortedDocuments) { - setDocuments(sortedDocuments) - } - if (sortedSubfolders) { - setSubfolders(sortedSubfolders) - } - - if (drawerDepth === 1) { - // not in a drawer (default is 1) - startRouteTransition(() => { - router.replace(newURL) - }) - } - }, - [drawerDepth, router, sortItems, startRouteTransition], - ) - - const separateItems = React.useCallback( - ( - items: FolderOrDocument[], - ): { - documents: FolderOrDocument[] - folders: FolderOrDocument[] - } => { - return items.reduce( - (acc, item) => { - if (item.relationTo === folderCollectionSlug) { - acc.folders.push(item) - } else { - acc.documents.push(item) - } - return acc - }, - { documents: [] as FolderOrDocument[], folders: [] as FolderOrDocument[] }, - ) - }, - [folderCollectionSlug], - ) - /** - * Used to remove items from the current state. + * Makes requests to the server to update the folder field on passed in documents * - * Does NOT handle the request to the server. - * Useful when a document is deleted and it needs to be removed - * from the current state. - */ - const removeItems: FolderContextValue['removeItems'] = React.useCallback( - (items) => { - if (!items.length) { - return - } - - const separatedItems = separateItems(items) - - if (separatedItems.documents.length) { - setDocuments((prevDocs) => { - return prevDocs.filter( - ({ itemKey }) => !separatedItems.documents.some((item) => item.itemKey === itemKey), - ) - }) - } - - if (separatedItems.folders.length) { - setSubfolders((prevFolders) => { - return prevFolders.filter( - ({ itemKey }) => !separatedItems.folders.some((item) => item.itemKey === itemKey), - ) - }) - } - - clearSelections() - }, - [clearSelections, separateItems, setDocuments, setSubfolders], - ) - - /** - * Used to add items to the current state. - * - * Does NOT handle the request to the server. - * Used when a document needs to be added to the current state. - */ - const addItems: FolderContextValue['addItems'] = React.useCallback( - (itemsToAdd) => { - const { items, parentItems } = itemsToAdd.reduce( - (acc, item) => { - const destinationFolderID = item.value.folderID || null - if ( - (item.value.folderID && item.value.folderID === activeFolderID) || - (!activeFolderID && !item.value.folderID) - ) { - acc.items.push(item) - } - - if ( - parentFolderContext && - ((parentFolderContext.folderID && - destinationFolderID === parentFolderContext.folderID) || - (!parentFolderContext.folderID && !item.value.folderID)) - ) { - acc.parentItems.push(item) - } - - return acc - }, - { items: [], parentItems: [] }, - ) - - if (parentItems.length) { - parentFolderContext.addItems(parentItems) - } - - if (!items.length) { - return - } - - const separatedItems = separateItems(items) - - let documentsToSort = undefined - let subfoldersToSort = undefined - - if (separatedItems.documents.length) { - documentsToSort = [...documents, ...separatedItems.documents] - } - - if (separatedItems.folders.length) { - subfoldersToSort = [...subfolders, ...separatedItems.folders] - } - - const { sortedDocuments, sortedSubfolders } = sortItems({ - documentsToSort, - subfoldersToSort, - }) - - const { sortedDocuments: sortedAllDocuments, sortedSubfolders: sortedAllSubfolders } = - sortItems({ - documentsToSort, - subfoldersToSort, - }) - - if (sortedDocuments) { - setDocuments(sortedDocuments) - setAllDocuments(sortedAllDocuments) - } - if (sortedSubfolders) { - setSubfolders(sortedSubfolders) - setAllSubfolders(sortedAllSubfolders) - } - }, - [activeFolderID, documents, separateItems, sortItems, subfolders, parentFolderContext], - ) - - /** - * Used to move items to a different folder. - * - * Handles the request to the server and updates the state. + * Might rewrite this in the future to return the promises so errors can be handled contextually */ const moveToFolder: FolderContextValue['moveToFolder'] = React.useCallback( async (args) => { @@ -903,11 +546,11 @@ export function FolderProvider({ const movingCurrentFolder = items.length === 1 && items[0].relationTo === folderCollectionSlug && - items[0].value.id === activeFolderID + items[0].value.id === folderID if (movingCurrentFolder) { const req = await fetch( - `${serverURL}${routes.api}/${folderCollectionSlug}/${activeFolderID}?depth=0`, + `${serverURL}${routes.api}/${folderCollectionSlug}/${folderID}?depth=0`, { body: JSON.stringify({ [folderFieldName]: toFolderID || null }), credentials: 'include', @@ -919,36 +562,12 @@ export function FolderProvider({ ) if (req.status !== 200) { toast.error(t('general:error')) - } else { - const updatedDoc = await req.json() - const folderRes = await getFolderData({ - folderID: updatedDoc.id, - }) - setBreadcrumbs(folderRes?.breadcrumbs || []) - setSubfolders(folderRes?.subfolders || []) - setDocuments(folderRes?.documents || []) - setActiveFolderID(updatedDoc.id) } - setBreadcrumbs((prevBreadcrumbs) => { - return prevBreadcrumbs.map((breadcrumb) => { - if (breadcrumb.id === activeFolderID) { - return { - ...breadcrumb, - id: toFolderID, - } - } - return breadcrumb - }) - }) } else { - const movingToCurrentFolder: boolean = toFolderID === activeFolderID - const successfullyMovedFolderItems: FolderOrDocument[] = [] - const successfullyMovedDocumentItems: FolderOrDocument[] = [] - for (const [collectionSlug, ids] of Object.entries(groupItemIDsByRelation(items))) { - const collectionConfig = getEntityConfig({ collectionSlug }) const query = qs.stringify( { + depth: 0, limit: 0, where: { id: { @@ -961,7 +580,7 @@ export function FolderProvider({ }, ) try { - const res = await fetch(`${serverURL}${routes.api}/${collectionSlug}${query}`, { + await fetch(`${serverURL}${routes.api}/${collectionSlug}${query}`, { body: JSON.stringify({ [folderFieldName]: toFolderID || null }), credentials: 'include', headers: { @@ -969,25 +588,6 @@ export function FolderProvider({ }, method: 'PATCH', }) - if (res.status === 200) { - const json = await res.json() - const { docs } = json as { docs: Document[] } - const formattedItems: FolderOrDocument[] = docs.map( - (doc: Document) => - formatFolderOrDocumentItem({ - folderFieldName, - isUpload: Boolean(collectionConfig.upload), - relationTo: collectionSlug, - useAsTitle: collectionConfig.admin.useAsTitle, - value: doc, - }), - ) - if (collectionSlug === folderCollectionSlug) { - successfullyMovedFolderItems.push(...formattedItems) - } else { - successfullyMovedDocumentItems.push(...formattedItems) - } - } } catch (error) { toast.error(t('general:error')) // eslint-disable-next-line no-console @@ -995,119 +595,26 @@ export function FolderProvider({ continue } } - - if (movingToCurrentFolder) { - // need to sort if we are moving (adding) items to the current folder - const { newURL, sortedDocuments, sortedSubfolders } = sortItems({ - documentsToSort: successfullyMovedDocumentItems.length - ? [...documents, ...successfullyMovedDocumentItems] - : undefined, - subfoldersToSort: successfullyMovedFolderItems.length - ? [...subfolders, ...successfullyMovedFolderItems] - : undefined, - }) - - if (sortedDocuments) { - setDocuments(sortedDocuments) - } - if (sortedSubfolders) { - setSubfolders(sortedSubfolders) - } - - if (drawerDepth === 1 && newURL) { - // not in a drawer (default is 1) - router.replace(newURL) - } - } else { - // no need to sort, just remove the items from the current state - const filteredDocuments = successfullyMovedDocumentItems.length - ? documents.filter( - ({ itemKey }) => - !successfullyMovedDocumentItems.some((item) => item.itemKey === itemKey), - ) - : undefined - const filteredSubfolders = successfullyMovedFolderItems.length - ? subfolders.filter( - ({ itemKey }) => - !successfullyMovedFolderItems.some((item) => item.itemKey === itemKey), - ) - : undefined - - if (filteredDocuments) { - setDocuments(filteredDocuments) - } - if (filteredSubfolders) { - setSubfolders(filteredSubfolders) - } - } } clearSelections() }, - [ - folderCollectionSlug, - activeFolderID, - clearSelections, - serverURL, - routes.api, - folderFieldName, - t, - getFolderData, - getEntityConfig, - sortItems, - documents, - subfolders, - drawerDepth, - router, - ], + [folderID, clearSelections, folderCollectionSlug, folderFieldName, routes.api, serverURL, t], ) - /** - * Used to rename a folder in the current state. - * - * Does NOT handle the request to the server. - * Used when the user renames a folder using the drawer - * and it needs to be updated in the current state. - */ - const renameFolder: FolderContextValue['renameFolder'] = React.useCallback( - ({ folderID: updatedFolderID, newName }) => { - if (activeFolderID === updatedFolderID) { - // updating the curent folder - setBreadcrumbs((prevBreadcrumbs) => { - return prevBreadcrumbs.map((breadcrumb) => { - if (breadcrumb.id === updatedFolderID) { - return { - ...breadcrumb, - name: newName, - } - } - return breadcrumb - }) - }) - } else { - setSubfolders((prevFolders) => { - return prevFolders.map((folder) => { - if (folder.value.id === updatedFolderID && folder.relationTo === folderCollectionSlug) { - return { - ...folder, - value: { - ...folder.value, - _folderOrDocumentTitle: newName, - }, - } - } - return folder - }) - }) - } - }, - [folderCollectionSlug, setSubfolders, activeFolderID], - ) + // If a new component is provided, update the state so children can re-render with the new component + React.useEffect(() => { + if (InitialFolderResultsComponent) { + setFolderResultsComponent(InitialFolderResultsComponent) + } + }, [InitialFolderResultsComponent]) return ( {children} diff --git a/packages/ui/src/providers/ServerFunctions/index.tsx b/packages/ui/src/providers/ServerFunctions/index.tsx index 995b08087..c78d98ec9 100644 --- a/packages/ui/src/providers/ServerFunctions/index.tsx +++ b/packages/ui/src/providers/ServerFunctions/index.tsx @@ -4,6 +4,7 @@ import type { Data, DocumentSlots, ErrorResult, + GetFolderResultsComponentAndDataArgs, Locale, ServerFunctionClient, } from 'payload' @@ -13,6 +14,7 @@ import React, { createContext, useCallback } from 'react' import type { buildFormStateHandler } from '../../utilities/buildFormState.js' import type { buildTableStateHandler } from '../../utilities/buildTableState.js' import type { CopyDataFromLocaleArgs } from '../../utilities/copyDataFromLocale.js' +import type { getFolderResultsComponentAndDataHandler } from '../../utilities/getFolderResultsComponentAndData.js' import type { schedulePublishHandler, SchedulePublishHandlerArgs, @@ -63,9 +65,16 @@ type GetDocumentSlots = (args: { signal?: AbortSignal }) => Promise +type GetFolderResultsComponentAndDataClient = ( + args: { + signal?: AbortSignal + } & Omit, +) => ReturnType + type ServerFunctionsContextType = { copyDataFromLocale: CopyDataFromLocaleClient getDocumentSlots: GetDocumentSlots + getFolderResultsComponentAndData: GetFolderResultsComponentAndDataClient getFormState: GetFormStateClient getTableState: GetTableStateClient renderDocument: RenderDocument @@ -218,11 +227,32 @@ export const ServerFunctionsProvider: React.FC<{ [serverFunction], ) + const getFolderResultsComponentAndData = useCallback( + async (args) => { + const { signal: remoteSignal, ...rest } = args || {} + + try { + const result = (await serverFunction({ + name: 'get-folder-results-component-and-data', + args: rest, + })) as Awaited> + + if (!remoteSignal?.aborted) { + return result + } + } catch (_err) { + console.error(_err) // eslint-disable-line no-console + } + }, + [serverFunction], + ) + return ( = ({ resetColumnsState, setActiveColumns, toggleColumn, - + // eslint-disable-next-line react-compiler/react-compiler ...contextRef.current, }} > diff --git a/packages/ui/src/utilities/getFolderResultsComponentAndData.tsx b/packages/ui/src/utilities/getFolderResultsComponentAndData.tsx new file mode 100644 index 000000000..0761f63c4 --- /dev/null +++ b/packages/ui/src/utilities/getFolderResultsComponentAndData.tsx @@ -0,0 +1,170 @@ +import type { + CollectionSlug, + ErrorResult, + GetFolderResultsComponentAndDataArgs, + Where, +} from 'payload' +import type { FolderBreadcrumb, FolderOrDocument } from 'payload/shared' + +import { APIError, formatErrors, getFolderData } from 'payload' +import { buildFolderWhereConstraints } from 'payload/shared' + +import { FolderFileTable } from '../elements/FolderView/FolderFileTable/index.js' +import { ItemCardGrid } from '../elements/FolderView/ItemCardGrid/index.js' + +type GetFolderResultsComponentAndDataResult = { + breadcrumbs?: FolderBreadcrumb[] + documents?: FolderOrDocument[] + FolderResultsComponent: React.ReactNode + subfolders?: FolderOrDocument[] +} + +type GetFolderResultsComponentAndDataErrorResult = { + breadcrumbs?: never + documents?: never + FolderResultsComponent?: never + subfolders?: never +} & ( + | { + message: string + } + | ErrorResult +) + +export const getFolderResultsComponentAndDataHandler = async ( + args: GetFolderResultsComponentAndDataArgs, +): Promise< + GetFolderResultsComponentAndDataErrorResult | GetFolderResultsComponentAndDataResult +> => { + const { req } = args + + try { + const res = await getFolderResultsComponentAndData(args) + return res + } catch (err) { + req.payload.logger.error({ err, msg: `There was an error building form state` }) + + if (err.message === 'Could not find field schema for given path') { + return { + message: err.message, + } + } + + if (err.message === 'Unauthorized') { + return null + } + + return formatErrors(err) + } +} + +/** + * This function is responsible for fetching folder data, building the results component + * and returns the data and component together. + * + * + * Open ended questions: + * - If we rerender the results section, does the provider update?? I dont think so, if the provider is on the server. + * Maybe we should move the provider to the client. + */ +export const getFolderResultsComponentAndData = async ({ + activeCollectionSlugs, + browseByFolder, + displayAs, + folderID = undefined, + req, + sort, +}: GetFolderResultsComponentAndDataArgs): Promise => { + const { payload } = req + + if (!payload.config.folders) { + throw new APIError('Folders are not enabled in the configuration.') + } + + let collectionSlug: CollectionSlug | undefined = undefined + let documentWhere: undefined | Where = undefined + let folderWhere: undefined | Where = undefined + + // todo(perf): - collect promises and resolve them in parallel + for (const activeCollectionSlug of activeCollectionSlugs) { + if (activeCollectionSlug === payload.config.folders.slug) { + const folderCollectionConstraints = await buildFolderWhereConstraints({ + collectionConfig: payload.collections[activeCollectionSlug].config, + folderID, + localeCode: req?.locale, + req, + search: typeof req?.query?.search === 'string' ? req.query.search : undefined, + sort, + }) + + if (folderCollectionConstraints) { + folderWhere = folderCollectionConstraints + } + } else if ((browseByFolder && folderID) || !browseByFolder) { + if (!browseByFolder) { + collectionSlug = activeCollectionSlug + } + + if (!documentWhere) { + documentWhere = { + or: [], + } + } + + const collectionConstraints = await buildFolderWhereConstraints({ + collectionConfig: payload.collections[activeCollectionSlug].config, + folderID, + localeCode: req?.locale, + req, + search: typeof req?.query?.search === 'string' ? req.query.search : undefined, + sort, + }) + + if (collectionConstraints) { + documentWhere.or.push(collectionConstraints) + } + } + } + + const folderData = await getFolderData({ + collectionSlug, + documentWhere, + folderID, + folderWhere, + req, + }) + + let FolderResultsComponent = null + + if (displayAs === 'grid') { + FolderResultsComponent = ( +
+ {folderData.subfolders.length ? ( + <> + + + ) : null} + + {folderData.documents.length ? ( + <> + + + ) : null} +
+ ) + } else { + FolderResultsComponent = + } + + return { + breadcrumbs: folderData.breadcrumbs, + documents: folderData.documents, + FolderResultsComponent, + subfolders: folderData.subfolders, + } +} diff --git a/packages/ui/src/views/BrowseByFolder/index.tsx b/packages/ui/src/views/BrowseByFolder/index.tsx index 5f0987a68..3e9771e6b 100644 --- a/packages/ui/src/views/BrowseByFolder/index.tsx +++ b/packages/ui/src/views/BrowseByFolder/index.tsx @@ -1,12 +1,11 @@ 'use client' import type { DragEndEvent } from '@dnd-kit/core' -import type { CollectionSlug, Document, FolderListViewClientProps } from 'payload' -import type { FolderDocumentItemKey } from 'payload/shared' +import type { FolderListViewClientProps } from 'payload' import { useDndMonitor } from '@dnd-kit/core' import { getTranslation } from '@payloadcms/translations' -import { formatFolderOrDocumentItem } from 'payload/shared' +import { useRouter } from 'next/navigation.js' import React, { Fragment } from 'react' import { DroppableBreadcrumb } from '../../elements/FolderView/Breadcrumbs/index.js' @@ -26,9 +25,10 @@ import { SearchBar } from '../../elements/SearchBar/index.js' import { useStepNav } from '../../elements/StepNav/index.js' import { useConfig } from '../../providers/Config/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js' -import { useFolder } from '../../providers/Folders/index.js' +import { FolderProvider, useFolder } from '../../providers/Folders/index.js' import { usePreferences } from '../../providers/Preferences/index.js' import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { useWindowInfo } from '../../providers/WindowInfo/index.js' import { ListSelection } from '../CollectionFolder/ListSelection/index.js' @@ -36,12 +36,54 @@ import './index.scss' const baseClass = 'folder-list' -export function DefaultBrowseByFolderView( - props: { - hasCreatePermissionCollectionSlugs?: string[] - selectedCollectionSlugs?: string[] - } & FolderListViewClientProps, -) { +export function DefaultBrowseByFolderView({ + activeCollectionFolderSlugs, + allCollectionFolderSlugs, + allowCreateCollectionSlugs, + baseFolderPath, + breadcrumbs, + documents, + folderFieldName, + folderID, + FolderResultsComponent, + search, + subfolders, + ...restOfProps +}: FolderListViewClientProps) { + return ( + + + + ) +} + +type BrowseByFolderViewInContextProps = Omit< + FolderListViewClientProps, + | 'activeCollectionFolderSlugs' + | 'allCollectionFolderSlugs' + | 'allowCreateCollectionSlugs' + | 'baseFolderPath' + | 'breadcrumbs' + | 'documents' + | 'folderFieldName' + | 'folderID' + | 'FolderResultsComponent' + | 'subfolders' +> + +function BrowseByFolderViewInContext(props: BrowseByFolderViewInContextProps) { const { AfterFolderList, AfterFolderListTable, @@ -50,40 +92,36 @@ export function DefaultBrowseByFolderView( Description, disableBulkDelete, disableBulkEdit, - hasCreatePermissionCollectionSlugs, viewPreference, } = props - const { config, getEntityConfig } = useConfig() + const router = useRouter() + const { getEntityConfig } = useConfig() const { i18n, t } = useTranslation() const drawerDepth = useEditDepth() const { setStepNav } = useStepNav() + const { startRouteTransition } = useRouteTransition() const { clearRouteCache } = useRouteCache() const { breakpoints: { s: smallBreak }, } = useWindowInfo() const { setPreference } = usePreferences() const { - addItems, + activeCollectionFolderSlugs: visibleCollectionSlugs, + allowCreateCollectionSlugs, breadcrumbs, documents, - filterItems, - focusedRowIndex, folderCollectionConfig, - folderFieldName, folderID, + getFolderRoute, getSelectedItems, - isDragging, lastSelectedIndex, moveToFolder, - onItemClick, - onItemKeyPress, + refineFolderData, search, - selectedIndexes, - setFolderID, + selectedItemKeys, setIsDragging, subfolders, - visibleCollectionSlugs, } = useFolder() const [activeView, setActiveView] = React.useState<'grid' | 'list'>(viewPreference || 'grid') @@ -129,35 +167,6 @@ export function DefaultBrowseByFolderView( return `${acc}, ${getTranslation(collectionConfig.labels?.plural, i18n)}` }, '') - const onCreateSuccess = React.useCallback( - ({ collectionSlug, doc }: { collectionSlug: CollectionSlug; doc: Document }) => { - const collectionConfig = getEntityConfig({ collectionSlug }) - void addItems([ - formatFolderOrDocumentItem({ - folderFieldName, - isUpload: Boolean(collectionConfig?.upload), - relationTo: collectionSlug, - useAsTitle: collectionConfig.admin.useAsTitle, - value: doc, - }), - ]) - }, - [getEntityConfig, addItems, folderFieldName], - ) - - const selectedItemKeys = React.useMemo(() => { - return new Set( - getSelectedItems().reduce((acc, item) => { - if (item) { - if (item.relationTo && item.value) { - acc.push(`${item.relationTo}-${item.value.id}`) - } - } - return acc - }, []), - ) - }, [getSelectedItems]) - const handleSetViewType = React.useCallback( (view: 'grid' | 'list') => { void setPreference('browse-by-folder', { @@ -192,7 +201,9 @@ export function DefaultBrowseByFolderView( id={null} key="root" onClick={() => { - void setFolderID({ folderID: null }) + startRouteTransition(() => { + router.push(getFolderRoute(null)) + }) }} > @@ -211,7 +222,9 @@ export function DefaultBrowseByFolderView( id={crumb.id} key={crumb.id} onClick={() => { - void setFolderID({ folderID: crumb.id }) + startRouteTransition(() => { + router.push(getFolderRoute(crumb.id)) + }) }} > {crumb.name} @@ -221,7 +234,7 @@ export function DefaultBrowseByFolderView( }), ]) } - }, [setStepNav, drawerDepth, i18n, breadcrumbs, setFolderID, t]) + }, [breadcrumbs, drawerDepth, getFolderRoute, router, setStepNav, startRouteTransition, t]) return ( @@ -242,13 +255,15 @@ export function DefaultBrowseByFolderView( AfterListHeaderContent={Description} title={listHeaderTitle} TitleActions={[ - , + allowCreateCollectionSlugs.length && ( + + ), ].filter(Boolean)} /> , ].filter(Boolean)} label={searchPlaceholder} - onSearchChange={(search) => filterItems({ search })} + onSearchChange={(search) => refineFolderData({ query: { search }, updateURL: true })} searchQueryParam={search} /> {BeforeFolderListTable} @@ -273,12 +288,7 @@ export function DefaultBrowseByFolderView(
{subfolders.length ? ( <> - + ) : null} @@ -286,7 +296,6 @@ export function DefaultBrowseByFolderView( <> ) : ( - + )} )} {totalDocsAndSubfolders === 0 && ( , - folderID && ( + allowCreateCollectionSlugs.includes(folderCollectionConfig.slug) && ( slug !== folderCollectionConfig.slug, - )} - key="create-document" - onCreateSuccess={onCreateSuccess} - slugPrefix="create-document--no-results" + buttonLabel={`${t('general:create')} ${getTranslation(folderCollectionConfig.labels?.singular, i18n).toLowerCase()}`} + collectionSlugs={[folderCollectionConfig.slug]} + key="create-folder" + onCreateSuccess={clearRouteCache} + slugPrefix="create-folder--no-results" /> ), + folderID && + allowCreateCollectionSlugs.filter((slug) => slug !== folderCollectionConfig.slug) + .length > 0 && ( + slug !== folderCollectionConfig.slug, + )} + key="create-document" + onCreateSuccess={clearRouteCache} + slugPrefix="create-document--no-results" + /> + ), ].filter(Boolean)} Message={

@@ -347,7 +350,7 @@ export function DefaultBrowseByFolderView( ) diff --git a/packages/ui/src/views/CollectionFolder/ListSelection/index.tsx b/packages/ui/src/views/CollectionFolder/ListSelection/index.tsx index ecf7725f9..cef1fffdf 100644 --- a/packages/ui/src/views/CollectionFolder/ListSelection/index.tsx +++ b/packages/ui/src/views/CollectionFolder/ListSelection/index.tsx @@ -1,7 +1,5 @@ 'use client' -import type { FolderOrDocument } from 'payload/shared' - import { useModal } from '@faceless-ui/modal' import { extractID } from 'payload/shared' import React, { Fragment } from 'react' @@ -16,6 +14,7 @@ import { PublishMany_v4 } from '../../../elements/PublishMany/index.js' import { UnpublishMany_v4 } from '../../../elements/UnpublishMany/index.js' import { useConfig } from '../../../providers/Config/index.js' import { useFolder } from '../../../providers/Folders/index.js' +import { useRouteCache } from '../../../providers/RouteCache/index.js' import { useTranslation } from '../../../providers/Translation/index.js' const moveToFolderDrawerSlug = 'move-to-folder--list' @@ -46,9 +45,8 @@ export const ListSelection: React.FC = ({ folderID, getSelectedItems, moveToFolder, - removeItems, - renameFolder, } = useFolder() + const { clearRouteCache } = useRouteCache() const { config } = useConfig() const { t } = useTranslation() const { closeModal, openModal } = useModal() @@ -121,12 +119,6 @@ export const ListSelection: React.FC = ({ folderCollectionSlug={folderCollectionSlug} id={groupedSelections[folderCollectionSlug].ids[0]} key="edit-folder-action" - onSave={({ doc }) => { - renameFolder({ - folderID: doc.id, - newName: doc[folderCollectionConfig.admin.useAsTitle], - }) - }} /> ), count > 0 ? ( @@ -178,25 +170,9 @@ export const ListSelection: React.FC = ({ ) : null, !disableBulkDelete && ( { - const itemsToRemove = Object.entries(groupedCollections).reduce( - (acc, [slug, res]) => { - if (res.ids.length) { - res.ids.forEach((id) => { - acc.push({ - itemKey: `${slug}-${id}`, - relationTo: slug, - value: { - id, - } as FolderOrDocument['value'], - }) - }) - } - return acc - }, - [], - ) - void removeItems(itemsToRemove) + afterDelete={() => { + clearRouteCache() + clearSelections() }} key="bulk-delete" selections={groupedSelections} diff --git a/packages/ui/src/views/CollectionFolder/index.tsx b/packages/ui/src/views/CollectionFolder/index.tsx index 051c0e214..678e55431 100644 --- a/packages/ui/src/views/CollectionFolder/index.tsx +++ b/packages/ui/src/views/CollectionFolder/index.tsx @@ -1,20 +1,18 @@ 'use client' import type { DragEndEvent } from '@dnd-kit/core' -import type { CollectionSlug, Document, FolderListViewClientProps } from 'payload' -import type { FolderDocumentItemKey } from 'payload/shared' +import type { FolderListViewClientProps } from 'payload' import { useDndMonitor } from '@dnd-kit/core' import { getTranslation } from '@payloadcms/translations' -import { formatFolderOrDocumentItem } from 'payload/shared' +import { useRouter } from 'next/navigation.js' +import { formatAdminURL } from 'payload/shared' import React, { Fragment } from 'react' import { DroppableBreadcrumb } from '../../elements/FolderView/Breadcrumbs/index.js' import { ColoredFolderIcon } from '../../elements/FolderView/ColoredFolderIcon/index.js' import { CurrentFolderActions } from '../../elements/FolderView/CurrentFolderActions/index.js' import { DragOverlaySelection } from '../../elements/FolderView/DragOverlaySelection/index.js' -import { FolderFileTable } from '../../elements/FolderView/FolderFileTable/index.js' -import { ItemCardGrid } from '../../elements/FolderView/ItemCardGrid/index.js' import { SortByPill } from '../../elements/FolderView/SortByPill/index.js' import { ToggleViewButtons } from '../../elements/FolderView/ToggleViewButtons/index.js' import { Gutter } from '../../elements/Gutter/index.js' @@ -29,8 +27,10 @@ import { SearchBar } from '../../elements/SearchBar/index.js' import { useStepNav } from '../../elements/StepNav/index.js' import { useConfig } from '../../providers/Config/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js' -import { useFolder } from '../../providers/Folders/index.js' +import { FolderProvider, useFolder } from '../../providers/Folders/index.js' import { usePreferences } from '../../providers/Preferences/index.js' +import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { useWindowInfo } from '../../providers/WindowInfo/index.js' import { ListSelection } from './ListSelection/index.js' @@ -38,7 +38,53 @@ import './index.scss' const baseClass = 'collection-folder-list' -export function DefaultCollectionFolderView(props: FolderListViewClientProps) { +export function DefaultCollectionFolderView({ + allCollectionFolderSlugs: folderCollectionSlugs, + allowCreateCollectionSlugs, + baseFolderPath, + breadcrumbs, + documents, + folderFieldName, + folderID, + FolderResultsComponent, + search, + sort, + subfolders, + ...restOfProps +}: FolderListViewClientProps) { + return ( + + + + ) +} + +type CollectionFolderViewInContextProps = Omit< + FolderListViewClientProps, + | 'allCollectionFolderSlugs' + | 'allowCreateCollectionSlugs' + | 'baseFolderPath' + | 'breadcrumbs' + | 'documents' + | 'folderFieldName' + | 'folderID' + | 'FolderResultsComponent' + | 'subfolders' +> + +function CollectionFolderViewInContext(props: CollectionFolderViewInContextProps) { const { AfterFolderList, AfterFolderListTable, @@ -48,7 +94,7 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { Description, disableBulkDelete, disableBulkEdit, - hasCreatePermission, + search, viewPreference, } = props @@ -58,28 +104,24 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { const { setStepNav } = useStepNav() const { setPreference } = usePreferences() const { - addItems, + allowCreateCollectionSlugs, breadcrumbs, documents, - filterItems, - focusedRowIndex, folderCollectionConfig, folderCollectionSlug, - folderFieldName, + FolderResultsComponent, getSelectedItems, - isDragging, lastSelectedIndex, moveToFolder, - onItemClick, - onItemKeyPress, - search, - selectedIndexes, - setFolderID, + refineFolderData, + selectedItemKeys, setIsDragging, subfolders, } = useFolder() - const [activeView, setActiveView] = React.useState<'grid' | 'list'>(viewPreference || 'grid') + const router = useRouter() + const { startRouteTransition } = useRouteTransition() + const { clearRouteCache } = useRouteCache() const collectionConfig = getEntityConfig({ collectionSlug }) @@ -98,52 +140,30 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { } if (event.over.data.current.type === 'folder' && 'id' in event.over.data.current) { - await moveToFolder({ - itemsToMove: getSelectedItems(), - toFolderID: event.over.data.current.id || null, - }) + try { + await moveToFolder({ + itemsToMove: getSelectedItems(), + toFolderID: event.over.data.current.id, + }) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error moving items:', error) + } + + clearRouteCache() } }, - [moveToFolder, getSelectedItems], + [moveToFolder, getSelectedItems, clearRouteCache], ) - const onCreateSuccess = React.useCallback( - ({ collectionSlug, doc }: { collectionSlug: CollectionSlug; doc: Document }) => { - const collectionConfig = getEntityConfig({ collectionSlug }) - void addItems([ - formatFolderOrDocumentItem({ - folderFieldName, - isUpload: Boolean(collectionConfig?.upload), - relationTo: collectionSlug, - useAsTitle: collectionConfig.admin.useAsTitle, - value: doc, - }), - ]) - }, - [getEntityConfig, addItems, folderFieldName], - ) - - const selectedItemKeys = React.useMemo(() => { - return new Set( - getSelectedItems().reduce((acc, item) => { - if (item) { - if (item.relationTo && item.value) { - acc.push(`${item.relationTo}-${item.value.id}`) - } - } - return acc - }, []), - ) - }, [getSelectedItems]) - const handleSetViewType = React.useCallback( - (view: 'grid' | 'list') => { - void setPreference(`${collectionSlug}-collection-folder`, { + async (view: 'grid' | 'list') => { + await setPreference(`${collectionSlug}-collection-folder`, { viewPreference: view, }) - setActiveView(view) + clearRouteCache() }, - [collectionSlug, setPreference], + [collectionSlug, setPreference, clearRouteCache], ) React.useEffect(() => { @@ -170,7 +190,16 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { id={null} key="root" onClick={() => { - void setFolderID({ folderID: null }) + startRouteTransition(() => { + if (config.folders) { + router.push( + formatAdminURL({ + adminRoute: config.routes.admin, + path: `/collections/${collectionSlug}/${config.folders.slug}`, + }), + ) + } + }) }} > @@ -189,7 +218,16 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { id={crumb.id} key={crumb.id} onClick={() => { - void setFolderID({ folderID: crumb.id }) + startRouteTransition(() => { + if (config.folders) { + router.push( + formatAdminURL({ + adminRoute: config.routes.admin, + path: `/collections/${collectionSlug}/${config.folders.slug}/${crumb.id}`, + }), + ) + } + }) }} > {crumb.name} @@ -199,13 +237,25 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { }), ]) } - }, [setStepNav, labels, drawerDepth, i18n, breadcrumbs, setFolderID]) + }, [ + breadcrumbs, + collectionSlug, + config.folders, + config.routes.admin, + drawerDepth, + i18n, + labels?.plural, + router, + setStepNav, + startRouteTransition, + ]) const totalDocsAndSubfolders = documents.length + subfolders.length return ( +

{BeforeFolderList} @@ -230,18 +280,18 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { AfterListHeaderContent={Description} title={getTranslation(labels?.plural, i18n)} TitleActions={[ - hasCreatePermission && ( + allowCreateCollectionSlugs.length && ( ), , @@ -251,7 +301,7 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { Actions={[ , , @@ -260,70 +310,32 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { label={t('general:searchBy', { label: t('general:name'), })} - onSearchChange={(search) => filterItems({ search })} + onSearchChange={(search) => refineFolderData({ query: { search }, updateURL: true })} searchQueryParam={search} /> {BeforeFolderListTable} - {totalDocsAndSubfolders > 0 && ( - <> - {activeView === 'grid' ? ( -
- {subfolders.length ? ( - <> - - - ) : null} - - {documents.length ? ( - <> - - - ) : null} -
- ) : ( - - )} - - )} + {totalDocsAndSubfolders > 0 && FolderResultsComponent} {totalDocsAndSubfolders === 0 && ( , - , + allowCreateCollectionSlugs.includes(folderCollectionSlug) && ( + + ), + allowCreateCollectionSlugs.includes(collectionSlug) && ( + + ), ].filter(Boolean)} Message={

@@ -344,11 +356,12 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) { ) } + function DndEventListener({ onDragEnd, setIsDragging }) { useDndMonitor({ onDragCancel() { diff --git a/test/folders/payload-types.ts b/test/folders/payload-types.ts index 1a1f3723c..276727a03 100644 --- a/test/folders/payload-types.ts +++ b/test/folders/payload-types.ts @@ -201,7 +201,7 @@ export interface FolderInterface { hasNextPage?: boolean; totalDocs?: number; }; - belongsToCollections?: ('posts' | 'media' | 'drafts' | 'autosave' | 'all')[] | null; + folderSlug?: string | null; updatedAt: string; createdAt: string; } @@ -419,7 +419,7 @@ export interface PayloadFoldersSelect { name?: T; folder?: T; documentsAndFolders?: T; - belongsToCollections?: T; + folderSlug?: T; updatedAt?: T; createdAt?: T; }