From 12539c61d4d47aa6caac217414b3dfad676bb2f3 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Thu, 17 Jul 2025 13:24:22 -0400 Subject: [PATCH] feat(ui): supports collection scoped folders (#12797) As discussed in [this RFC](https://github.com/payloadcms/payload/discussions/12729), this PR supports collection-scoped folders. You can scope folders to multiple collection types or just one. This unlocks the possibility to have folders on a per collection instead of always being shared on every collection. You can combine this feature with the `browseByFolder: false` to completely isolate a collection from other collections. Things left to do: - [x] ~~Create a custom react component for the selecting of collectionSlugs to filter out available options based on the current folders parameters~~ https://github.com/user-attachments/assets/14cb1f09-8d70-4cb9-b1e2-09da89302995 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1210564397815557 --- .github/workflows/main.yml | 2 + .../src/views/BrowseByFolder/buildView.tsx | 88 +++- .../src/views/CollectionFolders/buildView.tsx | 28 +- packages/payload/src/admin/functions/index.ts | 32 +- .../payload/src/admin/views/folderList.ts | 1 + packages/payload/src/config/defaults.ts | 17 +- packages/payload/src/config/sanitize.ts | 43 +- .../src/folders/addFolderCollection.ts | 51 ++ .../src/folders/addFolderCollections.ts | 56 --- .../src/folders/addFolderFieldToCollection.ts | 33 ++ .../payload/src/folders/buildFolderField.ts | 108 +++++ .../src/folders/createFolderCollection.ts | 163 ++++--- .../folders/endpoints/populateFolderData.ts | 135 ------ .../hooks/ensureSafeCollectionsChange.ts | 144 ++++++ packages/payload/src/folders/types.ts | 15 +- .../utils/formatFolderOrDocumentItem.ts | 1 + .../src/folders/utils/getFolderBreadcrumbs.ts | 2 + .../src/folders/utils/getFolderData.ts | 45 +- .../utils/getFoldersAndDocumentsFromJoin.ts | 8 +- .../utilities/combineWhereConstraints.spec.ts | 86 ++++ .../src/utilities/combineWhereConstraints.ts | 25 +- packages/translations/src/clientKeys.ts | 1 + packages/translations/src/languages/ar.ts | 1 + packages/translations/src/languages/az.ts | 1 + packages/translations/src/languages/bg.ts | 2 + packages/translations/src/languages/bnBd.ts | 2 + packages/translations/src/languages/bnIn.ts | 2 + packages/translations/src/languages/ca.ts | 2 + packages/translations/src/languages/cs.ts | 2 + packages/translations/src/languages/da.ts | 2 + packages/translations/src/languages/de.ts | 2 + packages/translations/src/languages/en.ts | 2 + packages/translations/src/languages/es.ts | 2 + packages/translations/src/languages/et.ts | 1 + packages/translations/src/languages/fa.ts | 1 + packages/translations/src/languages/fr.ts | 2 + packages/translations/src/languages/he.ts | 1 + packages/translations/src/languages/hr.ts | 2 + packages/translations/src/languages/hu.ts | 2 + packages/translations/src/languages/hy.ts | 2 + packages/translations/src/languages/it.ts | 2 + packages/translations/src/languages/ja.ts | 2 + packages/translations/src/languages/ko.ts | 1 + packages/translations/src/languages/lt.ts | 2 + packages/translations/src/languages/lv.ts | 2 + packages/translations/src/languages/my.ts | 1 + packages/translations/src/languages/nb.ts | 1 + packages/translations/src/languages/nl.ts | 2 + packages/translations/src/languages/pl.ts | 2 + packages/translations/src/languages/pt.ts | 2 + packages/translations/src/languages/ro.ts | 2 + packages/translations/src/languages/rs.ts | 2 + .../translations/src/languages/rsLatin.ts | 2 + packages/translations/src/languages/ru.ts | 2 + packages/translations/src/languages/sk.ts | 2 + packages/translations/src/languages/sl.ts | 2 + packages/translations/src/languages/sv.ts | 1 + packages/translations/src/languages/th.ts | 1 + packages/translations/src/languages/tr.ts | 2 + packages/translations/src/languages/uk.ts | 2 + packages/translations/src/languages/vi.ts | 1 + packages/translations/src/languages/zh.ts | 1 + packages/translations/src/languages/zhTw.ts | 1 + .../elements/FolderView/Breadcrumbs/index.tsx | 9 +- .../FolderView/CurrentFolderActions/index.tsx | 1 + .../FolderView/DragOverlaySelection/index.tsx | 12 +- .../FolderView/DraggableTableRow/index.tsx | 1 - .../FolderView/DraggableWithClick/index.scss | 2 +- .../FolderView/DraggableWithClick/index.tsx | 20 +- .../Drawers/MoveToFolder/index.scss | 4 + .../FolderView/Drawers/MoveToFolder/index.tsx | 16 +- .../index.scss | 0 .../index.tsx | 2 +- .../{Field => FolderField}/index.scss | 0 .../{Field => FolderField}/index.server.tsx | 2 +- .../FolderView/FolderFileCard/index.scss | 48 +- .../FolderView/FolderFileCard/index.tsx | 75 ++- .../FolderView/FolderFileTable/index.tsx | 32 +- .../FolderView/FolderTypeField/index.tsx | 140 ++++++ .../FolderView/MoveDocToFolder/index.tsx | 5 +- .../elements/FolderView/SortByPill/index.tsx | 31 +- .../ListCreateNewDocInFolderButton.tsx | 5 + packages/ui/src/exports/client/index.ts | 3 +- packages/ui/src/exports/rsc/index.ts | 2 +- packages/ui/src/fields/Checkbox/Input.tsx | 6 +- packages/ui/src/fields/Select/index.tsx | 2 +- .../Folders/groupItemIDsByRelation.ts | 15 + packages/ui/src/providers/Folders/index.tsx | 455 +++++++++++++----- .../ui/src/providers/Folders/selection.ts | 52 -- .../getFolderResultsComponentAndData.tsx | 75 ++- .../ui/src/views/BrowseByFolder/index.tsx | 46 +- .../CollectionFolder/ListSelection/index.tsx | 7 +- .../ui/src/views/CollectionFolder/index.tsx | 23 +- test/folders/e2e.spec.ts | 339 +++++++++---- test/folders/int.spec.ts | 169 ++++++- test/folders/payload-types.ts | 2 + test/folders/tsconfig.json | 3 + .../folders/applyBrowseByFolderTypeFilter.ts | 41 ++ test/helpers/folders/clickFolderCard.ts | 22 +- test/helpers/folders/createFolder.ts | 24 +- test/helpers/folders/createFolderDoc.ts | 26 + test/helpers/folders/createFolderFromDoc.ts | 25 +- 102 files changed, 2127 insertions(+), 768 deletions(-) create mode 100644 packages/payload/src/folders/addFolderCollection.ts delete mode 100644 packages/payload/src/folders/addFolderCollections.ts create mode 100644 packages/payload/src/folders/addFolderFieldToCollection.ts create mode 100644 packages/payload/src/folders/buildFolderField.ts delete mode 100644 packages/payload/src/folders/endpoints/populateFolderData.ts create mode 100644 packages/payload/src/folders/hooks/ensureSafeCollectionsChange.ts create mode 100644 packages/payload/src/utilities/combineWhereConstraints.spec.ts rename packages/ui/src/elements/FolderView/{CollectionTypePill => FilterFolderTypePill}/index.scss (100%) rename packages/ui/src/elements/FolderView/{CollectionTypePill => FilterFolderTypePill}/index.tsx (97%) rename packages/ui/src/elements/FolderView/{Field => FolderField}/index.scss (100%) rename packages/ui/src/elements/FolderView/{Field => FolderField}/index.server.tsx (87%) create mode 100644 packages/ui/src/elements/FolderView/FolderTypeField/index.tsx create mode 100644 packages/ui/src/providers/Folders/groupItemIDsByRelation.ts delete mode 100644 packages/ui/src/providers/Folders/selection.ts create mode 100644 test/folders/tsconfig.json create mode 100644 test/helpers/folders/applyBrowseByFolderTypeFilter.ts create mode 100644 test/helpers/folders/createFolderDoc.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 60b6ac9655..e10abad457 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -284,6 +284,7 @@ jobs: - fields__collections__Text - fields__collections__UI - fields__collections__Upload + - folders - hooks - lexical__collections__Lexical__e2e__main - lexical__collections__Lexical__e2e__blocks @@ -418,6 +419,7 @@ jobs: - fields__collections__Text - fields__collections__UI - fields__collections__Upload + - folders - hooks - lexical__collections__Lexical__e2e__main - lexical__collections__Lexical__e2e__blocks diff --git a/packages/next/src/views/BrowseByFolder/buildView.tsx b/packages/next/src/views/BrowseByFolder/buildView.tsx index b57da8a60a..59775ad7d3 100644 --- a/packages/next/src/views/BrowseByFolder/buildView.tsx +++ b/packages/next/src/views/BrowseByFolder/buildView.tsx @@ -58,20 +58,45 @@ export const buildBrowseByFolderView = async ( throw new Error('not-found') } - const browseByFolderSlugs = browseByFolderSlugsFromArgs.filter( + const foldersSlug = config.folders.slug + + /** + * All visiible folder enabled collection slugs that the user has read permissions for. + */ + const allowReadCollectionSlugs = browseByFolderSlugsFromArgs.filter( (collectionSlug) => permissions?.collections?.[collectionSlug]?.read && visibleEntities.collections.includes(collectionSlug), ) - const query = queryFromArgs || queryFromReq - const activeCollectionFolderSlugs: string[] = - Array.isArray(query?.relationTo) && query.relationTo.length - ? query.relationTo.filter( - (slug) => - browseByFolderSlugs.includes(slug) || (config.folders && slug === config.folders.slug), - ) - : [...browseByFolderSlugs, config.folders.slug] + const query = + queryFromArgs || + ((queryFromReq + ? { + ...queryFromReq, + relationTo: + typeof queryFromReq?.relationTo === 'string' + ? JSON.parse(queryFromReq.relationTo) + : undefined, + } + : {}) as ListQuery) + + /** + * If a folderID is provided and the relationTo query param exists, + * we filter the collection slugs to only those that are allowed to be read. + * + * If no folderID is provided, only folders should be active and displayed (the root view). + */ + let collectionsToDisplay: string[] = [] + if (folderID && Array.isArray(query?.relationTo)) { + collectionsToDisplay = query.relationTo.filter( + (slug) => allowReadCollectionSlugs.includes(slug) || slug === foldersSlug, + ) + } else if (folderID) { + collectionsToDisplay = [...allowReadCollectionSlugs, foldersSlug] + } else { + collectionsToDisplay = [foldersSlug] + } const { routes: { admin: adminRoute }, @@ -93,14 +118,15 @@ export const buildBrowseByFolderView = async ( }, }) - const sortPreference: FolderSortKeys = browseByFolderPreferences?.sort || '_folderOrDocumentTitle' + const sortPreference: FolderSortKeys = browseByFolderPreferences?.sort || 'name' const viewPreference = browseByFolderPreferences?.viewPreference || 'grid' - const { breadcrumbs, documents, FolderResultsComponent, subfolders } = + const { breadcrumbs, documents, folderAssignedCollections, FolderResultsComponent, subfolders } = await getFolderResultsComponentAndData({ - activeCollectionSlugs: activeCollectionFolderSlugs, - browseByFolder: false, + browseByFolder: true, + collectionsToDisplay, displayAs: viewPreference, + folderAssignedCollections: collectionsToDisplay.filter((slug) => slug !== foldersSlug) || [], folderID, req: initPageResult.req, sort: sortPreference, @@ -142,10 +168,33 @@ export const buildBrowseByFolderView = async ( // serverProps, // }) - // documents cannot be created without a parent folder in this view - const allowCreateCollectionSlugs = resolvedFolderID - ? [config.folders.slug, ...browseByFolderSlugs] - : [config.folders.slug] + // Filter down allCollectionFolderSlugs by the ones the current folder is assingned to + const allAvailableCollectionSlugs = + folderID && Array.isArray(folderAssignedCollections) && folderAssignedCollections.length + ? allowReadCollectionSlugs.filter((slug) => folderAssignedCollections.includes(slug)) + : allowReadCollectionSlugs + + // Filter down activeCollectionFolderSlugs by the ones the current folder is assingned to + const availableActiveCollectionFolderSlugs = collectionsToDisplay.filter((slug) => { + if (slug === foldersSlug) { + return permissions?.collections?.[foldersSlug]?.read + } else { + return !folderAssignedCollections || folderAssignedCollections.includes(slug) + } + }) + + // Documents cannot be created without a parent folder in this view + const allowCreateCollectionSlugs = ( + resolvedFolderID ? [foldersSlug, ...allAvailableCollectionSlugs] : [foldersSlug] + ).filter((collectionSlug) => { + if (collectionSlug === foldersSlug) { + return permissions?.collections?.[foldersSlug]?.create + } + return ( + permissions?.collections?.[collectionSlug]?.create && + visibleEntities.collections.includes(collectionSlug) + ) + }) return { View: ( @@ -154,8 +203,8 @@ export const buildBrowseByFolderView = async ( {RenderServerComponent({ clientProps: { // ...folderViewSlots, - activeCollectionFolderSlugs, - allCollectionFolderSlugs: browseByFolderSlugs, + activeCollectionFolderSlugs: availableActiveCollectionFolderSlugs, + allCollectionFolderSlugs: allAvailableCollectionSlugs, allowCreateCollectionSlugs, baseFolderPath: `/browse-by-folder`, breadcrumbs, @@ -163,6 +212,7 @@ export const buildBrowseByFolderView = async ( disableBulkEdit, documents, enableRowSelections, + folderAssignedCollections, folderFieldName: config.folders.fieldName, folderID: resolvedFolderID || null, FolderResultsComponent, diff --git a/packages/next/src/views/CollectionFolders/buildView.tsx b/packages/next/src/views/CollectionFolders/buildView.tsx index e06bfcc2c0..8a110f20f7 100644 --- a/packages/next/src/views/CollectionFolders/buildView.tsx +++ b/packages/next/src/views/CollectionFolders/buildView.tsx @@ -97,23 +97,28 @@ export const buildCollectionFolderView = async ( }, }) - const sortPreference: FolderSortKeys = - collectionFolderPreferences?.sort || '_folderOrDocumentTitle' + const sortPreference: FolderSortKeys = collectionFolderPreferences?.sort || 'name' const viewPreference = collectionFolderPreferences?.viewPreference || 'grid' const { routes: { admin: adminRoute }, } = config - const { breadcrumbs, documents, FolderResultsComponent, subfolders } = - await getFolderResultsComponentAndData({ - activeCollectionSlugs: [config.folders.slug, collectionSlug], - browseByFolder: false, - displayAs: viewPreference, - folderID, - req: initPageResult.req, - sort: sortPreference, - }) + const { + breadcrumbs, + documents, + folderAssignedCollections, + FolderResultsComponent, + subfolders, + } = await getFolderResultsComponentAndData({ + browseByFolder: false, + collectionsToDisplay: [config.folders.slug, collectionSlug], + displayAs: viewPreference, + folderAssignedCollections: [collectionSlug], + folderID, + req: initPageResult.req, + sort: sortPreference, + }) const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id @@ -182,6 +187,7 @@ export const buildCollectionFolderView = async ( disableBulkEdit, documents, enableRowSelections, + folderAssignedCollections, folderFieldName: config.folders.fieldName, folderID: resolvedFolderID || null, FolderResultsComponent, diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index e3676a10a7..d14fd04d3a 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -1,7 +1,7 @@ import type { ImportMap } from '../../bin/generateImportMap/index.js' import type { SanitizedConfig } from '../../config/types.js' import type { PaginatedDocs } from '../../database/types.js' -import type { CollectionSlug, ColumnPreference } from '../../index.js' +import type { CollectionSlug, ColumnPreference, FolderSortKeys } from '../../index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js' import type { ColumnsFromURL } from '../../utilities/transformColumnPreferences.js' @@ -78,10 +78,36 @@ export type BuildCollectionFolderViewResult = { } export type GetFolderResultsComponentAndDataArgs = { - activeCollectionSlugs: CollectionSlug[] + /** + * If true and no folderID is provided, only folders will be returned. + * If false, the results will include documents from the active collections. + */ browseByFolder: boolean + /** + * Used to filter document types to include in the results/display. + * + * i.e. ['folders', 'posts'] will only include folders and posts in the results. + * + * collectionsToQuery? + */ + collectionsToDisplay: CollectionSlug[] + /** + * Used to determine how the results should be displayed. + */ displayAs: 'grid' | 'list' + /** + * Used to filter folders by the collections they are assigned to. + * + * i.e. ['posts'] will only include folders that are assigned to the posts collections. + */ + folderAssignedCollections: CollectionSlug[] + /** + * The ID of the folder to filter results by. + */ folderID: number | string | undefined req: PayloadRequest - sort: string + /** + * The sort order for the results. + */ + sort: FolderSortKeys } diff --git a/packages/payload/src/admin/views/folderList.ts b/packages/payload/src/admin/views/folderList.ts index b1074fe467..18b9aac736 100644 --- a/packages/payload/src/admin/views/folderList.ts +++ b/packages/payload/src/admin/views/folderList.ts @@ -30,6 +30,7 @@ export type FolderListViewClientProps = { disableBulkEdit?: boolean documents: FolderOrDocument[] enableRowSelections?: boolean + folderAssignedCollections?: SanitizedCollectionConfig['slug'][] folderFieldName: string folderID: null | number | string FolderResultsComponent: React.ReactNode diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index b5a4063bb3..77dc94677c 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -163,14 +163,17 @@ export const addDefaultsToConfig = (config: Config): Config => { ...(config.auth || {}), } - const hasFolderCollections = config.collections.some((collection) => Boolean(collection.folders)) - if (hasFolderCollections) { + if ( + config.folders !== false && + config.collections.some((collection) => Boolean(collection.folders)) + ) { config.folders = { - slug: foldersSlug, - browseByFolder: true, - debug: false, - fieldName: parentFolderFieldName, - ...(config.folders || {}), + slug: config.folders?.slug ?? foldersSlug, + browseByFolder: config.folders?.browseByFolder ?? true, + collectionOverrides: config.folders?.collectionOverrides || undefined, + collectionSpecific: config.folders?.collectionSpecific ?? true, + debug: config.folders?.debug ?? false, + fieldName: config.folders?.fieldName ?? parentFolderFieldName, } } else { config.folders = false diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index d79b2d73fc..c90ee9703b 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -3,6 +3,7 @@ import type { AcceptedLanguages } from '@payloadcms/translations' import { en } from '@payloadcms/translations/languages/en' import { deepMergeSimple } from '@payloadcms/translations/utilities' +import type { CollectionSlug, GlobalSlug, SanitizedCollectionConfig } from '../index.js' import type { SanitizedJobsConfig } from '../queues/config/types/index.js' import type { Config, @@ -18,15 +19,10 @@ import { sanitizeCollection } from '../collections/config/sanitize.js' import { migrationsCollection } from '../database/migrations/migrationsCollection.js' import { DuplicateCollection, InvalidConfiguration } from '../errors/index.js' import { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js' -import { addFolderCollections } from '../folders/addFolderCollections.js' +import { addFolderCollection } from '../folders/addFolderCollection.js' +import { addFolderFieldToCollection } from '../folders/addFolderFieldToCollection.js' import { sanitizeGlobal } from '../globals/config/sanitize.js' -import { - baseBlockFields, - type CollectionSlug, - formatLabels, - type GlobalSlug, - sanitizeFields, -} from '../index.js' +import { baseBlockFields, formatLabels, sanitizeFields } from '../index.js' import { getLockedDocumentsCollection, lockedDocumentsCollectionSlug, @@ -191,8 +187,6 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise() - await addFolderCollections(config as unknown as Config) - const validRelationships = [ ...(config.collections?.map((c) => c.slug) ?? []), jobsCollectionSlug, @@ -200,6 +194,10 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise 0) { @@ -332,6 +345,16 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise + folderEnabledCollections: CollectionConfig[] + richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise> + validRelationships?: string[] +}): Promise { + if (config.folders === false) { + return + } + + let folderCollectionConfig = createFolderCollection({ + slug: config.folders!.slug as string, + collectionSpecific, + debug: config.folders!.debug, + folderEnabledCollections, + folderFieldName: config.folders!.fieldName as string, + }) + + const collectionIndex = config.collections!.push(folderCollectionConfig) + + if ( + Array.isArray(config.folders?.collectionOverrides) && + config?.folders.collectionOverrides.length + ) { + for (const override of config.folders.collectionOverrides) { + folderCollectionConfig = await override({ collection: folderCollectionConfig }) + } + } + + const sanitizedCollectionWithOverrides = await sanitizeCollection( + config as unknown as Config, + folderCollectionConfig, + richTextSanitizationPromises, + validRelationships, + ) + + config.collections![collectionIndex - 1] = sanitizedCollectionWithOverrides +} diff --git a/packages/payload/src/folders/addFolderCollections.ts b/packages/payload/src/folders/addFolderCollections.ts deleted file mode 100644 index deb9323197..0000000000 --- a/packages/payload/src/folders/addFolderCollections.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Config } from '../config/types.js' -import type { CollectionSlug } from '../index.js' - -import { createFolderCollection } from './createFolderCollection.js' - -export async function addFolderCollections(config: NonNullable): Promise { - if (!config.collections || !config.folders) { - return - } - - const enabledCollectionSlugs: CollectionSlug[] = [] - const debug = Boolean(config?.folders?.debug) - const folderFieldName = config?.folders?.fieldName as unknown as string - const folderSlug = config?.folders?.slug as unknown as CollectionSlug - - for (let i = 0; i < config.collections.length; i++) { - const collection = config.collections[i] - if (collection && collection?.folders) { - collection.fields.push({ - name: folderFieldName, - type: 'relationship', - admin: { - allowCreate: false, - allowEdit: false, - components: { - Cell: '@payloadcms/ui/rsc#FolderTableCell', - Field: '@payloadcms/ui/rsc#FolderEditField', - }, - }, - index: true, - label: 'Folder', - relationTo: folderSlug, - }) - enabledCollectionSlugs.push(collection.slug) - } - } - - if (enabledCollectionSlugs.length) { - let folderCollection = createFolderCollection({ - slug: folderSlug, - collectionSlugs: enabledCollectionSlugs, - debug, - folderFieldName, - }) - - if ( - Array.isArray(config?.folders?.collectionOverrides) && - config?.folders.collectionOverrides.length - ) { - for (const override of config.folders.collectionOverrides) { - folderCollection = await override({ collection: folderCollection }) - } - } - config.collections.push(folderCollection) - } -} diff --git a/packages/payload/src/folders/addFolderFieldToCollection.ts b/packages/payload/src/folders/addFolderFieldToCollection.ts new file mode 100644 index 0000000000..a4aa6c6860 --- /dev/null +++ b/packages/payload/src/folders/addFolderFieldToCollection.ts @@ -0,0 +1,33 @@ +import type { SanitizedCollectionConfig } from '../index.js' + +import { buildFolderField } from './buildFolderField.js' + +export const addFolderFieldToCollection = ({ + collection, + collectionSpecific, + folderFieldName, + folderSlug, +}: { + collection: SanitizedCollectionConfig + collectionSpecific: boolean + folderFieldName: string + folderSlug: string +}): void => { + collection.fields.push( + buildFolderField({ + collectionSpecific, + folderFieldName, + folderSlug, + overrides: { + admin: { + allowCreate: false, + allowEdit: false, + components: { + Cell: '@payloadcms/ui/rsc#FolderTableCell', + Field: '@payloadcms/ui/rsc#FolderField', + }, + }, + }, + }), + ) +} diff --git a/packages/payload/src/folders/buildFolderField.ts b/packages/payload/src/folders/buildFolderField.ts new file mode 100644 index 0000000000..c3920a4d58 --- /dev/null +++ b/packages/payload/src/folders/buildFolderField.ts @@ -0,0 +1,108 @@ +import type { SingleRelationshipField } from '../fields/config/types.js' +import type { Document } from '../types/index.js' + +import { extractID } from '../utilities/extractID.js' + +export const buildFolderField = ({ + collectionSpecific, + folderFieldName, + folderSlug, + overrides = {}, +}: { + collectionSpecific: boolean + folderFieldName: string + folderSlug: string + overrides?: Partial +}): SingleRelationshipField => { + const field: SingleRelationshipField = { + name: folderFieldName, + type: 'relationship', + admin: {}, + hasMany: false, + index: true, + label: 'Folder', + relationTo: folderSlug, + validate: async (value, { collectionSlug, data, overrideAccess, previousValue, req }) => { + if (!collectionSpecific) { + // if collection scoping is not enabled, no validation required since folders can contain any type of document + return true + } + + if (!value) { + // no folder, no validation required + return true + } + + const newID = extractID(value) + if (previousValue && extractID(previousValue) === newID) { + // value did not change, no validation required + return true + } else { + // need to validat the folder value allows this collection type + let parentFolder: Document = null + if (typeof value === 'string' || typeof value === 'number') { + // need to populate the value with the document + parentFolder = await req.payload.findByID({ + id: newID, + collection: folderSlug, + depth: 0, // no need to populate nested folders + overrideAccess, + req, + select: { + folderType: true, // only need to check folderType + }, + user: req.user, + }) + } + + if (parentFolder && collectionSlug) { + const parentFolderTypes: string[] = (parentFolder.folderType as string[]) || [] + + // if the parent folder has no folder types, it accepts all collections + if (parentFolderTypes.length === 0) { + return true + } + + // validation for a folder document + if (collectionSlug === folderSlug) { + // ensure the parent accepts ALL folder types + const folderTypes: string[] = 'folderType' in data ? (data.folderType as string[]) : [] + const invalidSlugs = folderTypes.filter((validCollectionSlug: string) => { + return !parentFolderTypes.includes(validCollectionSlug) + }) + if (invalidSlugs.length === 0) { + return true + } else { + return `Folder with ID ${newID} does not allow documents of type ${invalidSlugs.join(', ')}` + } + } + + // validation for a non-folder document + if (parentFolderTypes.includes(collectionSlug)) { + return true + } else { + return `Folder with ID ${newID} does not allow documents of type ${collectionSlug}` + } + } else { + return `Folder with ID ${newID} not found in collection ${folderSlug}` + } + } + }, + } + + if (overrides?.admin) { + field.admin = { + ...field.admin, + ...(overrides.admin || {}), + } + + if (overrides.admin.components) { + field.admin.components = { + ...field.admin.components, + ...(overrides.admin.components || {}), + } + } + } + + return field +} diff --git a/packages/payload/src/folders/createFolderCollection.ts b/packages/payload/src/folders/createFolderCollection.ts index 4da3e3bee7..9e1b8e93cd 100644 --- a/packages/payload/src/folders/createFolderCollection.ts +++ b/packages/payload/src/folders/createFolderCollection.ts @@ -1,74 +1,129 @@ import type { CollectionConfig } from '../collections/config/types.js' +import type { Field, Option, SelectField } from '../fields/config/types.js' -import { populateFolderDataEndpoint } from './endpoints/populateFolderData.js' +import { defaultAccess } from '../auth/defaultAccess.js' +import { buildFolderField } from './buildFolderField.js' +import { foldersSlug } from './constants.js' import { deleteSubfoldersBeforeDelete } from './hooks/deleteSubfoldersAfterDelete.js' import { dissasociateAfterDelete } from './hooks/dissasociateAfterDelete.js' +import { ensureSafeCollectionsChange } from './hooks/ensureSafeCollectionsChange.js' import { reparentChildFolder } from './hooks/reparentChildFolder.js' type CreateFolderCollectionArgs = { - collectionSlugs: string[] + collectionSpecific: boolean debug?: boolean + folderEnabledCollections: CollectionConfig[] folderFieldName: string slug: string } export const createFolderCollection = ({ slug, - collectionSlugs, + collectionSpecific, debug, + folderEnabledCollections, folderFieldName, -}: CreateFolderCollectionArgs): CollectionConfig => ({ - slug, - admin: { - hidden: !debug, - useAsTitle: 'name', - }, - endpoints: [populateFolderDataEndpoint], - fields: [ - { - name: 'name', - type: 'text', - index: true, - required: true, +}: CreateFolderCollectionArgs): CollectionConfig => { + const { collectionOptions, collectionSlugs } = folderEnabledCollections.reduce( + (acc, collection: CollectionConfig) => { + acc.collectionSlugs.push(collection.slug) + acc.collectionOptions.push({ + label: collection.labels?.plural || collection.slug, + value: collection.slug, + }) + + return acc }, { - name: folderFieldName, - type: 'relationship', - admin: { - hidden: !debug, + collectionOptions: [] as Option[], + collectionSlugs: [] as string[], + }, + ) + + return { + slug, + access: { + create: defaultAccess, + delete: defaultAccess, + read: defaultAccess, + readVersions: defaultAccess, + update: defaultAccess, + }, + admin: { + hidden: !debug, + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + index: true, + required: true, }, - index: true, - relationTo: slug, - }, - { - name: 'documentsAndFolders', - type: 'join', - admin: { - hidden: !debug, + buildFolderField({ + collectionSpecific, + folderFieldName, + folderSlug: slug, + overrides: { + admin: { + hidden: !debug, + }, + }, + }), + { + name: 'documentsAndFolders', + type: 'join', + admin: { + hidden: !debug, + }, + collection: [slug, ...collectionSlugs], + hasMany: true, + on: folderFieldName, }, - collection: [slug, ...collectionSlugs], - hasMany: true, - on: folderFieldName, + ...(collectionSpecific + ? [ + { + name: 'folderType', + type: 'select', + admin: { + components: { + Field: { + clientProps: { + options: collectionOptions, + }, + path: '@payloadcms/ui#FolderTypeField', + }, + }, + position: 'sidebar', + }, + hasMany: true, + options: collectionOptions, + } satisfies SelectField, + ] + : ([] as Field[])), + ], + hooks: { + afterChange: [ + reparentChildFolder({ + folderFieldName, + }), + ], + afterDelete: [ + dissasociateAfterDelete({ + collectionSlugs, + folderFieldName, + }), + ], + beforeDelete: [deleteSubfoldersBeforeDelete({ folderFieldName, folderSlug: slug })], + beforeValidate: [ + ...(collectionSpecific ? [ensureSafeCollectionsChange({ foldersSlug })] : []), + ], }, - ], - hooks: { - afterChange: [ - reparentChildFolder({ - folderFieldName, - }), - ], - afterDelete: [ - dissasociateAfterDelete({ - collectionSlugs, - folderFieldName, - }), - ], - beforeDelete: [deleteSubfoldersBeforeDelete({ folderFieldName, folderSlug: slug })], - }, - labels: { - plural: 'Folders', - singular: 'Folder', - }, - typescript: { - interface: 'FolderInterface', - }, -}) + labels: { + plural: 'Folders', + singular: 'Folder', + }, + typescript: { + interface: 'FolderInterface', + }, + } +} diff --git a/packages/payload/src/folders/endpoints/populateFolderData.ts b/packages/payload/src/folders/endpoints/populateFolderData.ts deleted file mode 100644 index 9347602a9e..0000000000 --- a/packages/payload/src/folders/endpoints/populateFolderData.ts +++ /dev/null @@ -1,135 +0,0 @@ -import httpStatus from 'http-status' - -import type { Endpoint, Where } from '../../index.js' - -import { buildFolderWhereConstraints } from '../utils/buildFolderWhereConstraints.js' -import { getFolderData } from '../utils/getFolderData.js' - -export const populateFolderDataEndpoint: Endpoint = { - handler: async (req) => { - if (!req?.user) { - return Response.json( - { - message: 'Unauthorized request.', - }, - { - status: httpStatus.UNAUTHORIZED, - }, - ) - } - - if ( - !( - req.payload.config.folders && - Boolean(req.payload.collections?.[req.payload.config.folders.slug]) - ) - ) { - return Response.json( - { - message: 'Folders are not configured', - }, - { - status: httpStatus.NOT_FOUND, - }, - ) - } - - // if collectionSlug exists, we need to create constraints for that _specific collection_ and the folder collection - // if collectionSlug does not exist, we need to create constraints for _all folder enabled collections_ and the folder collection - let documentWhere: undefined | Where - let folderWhere: undefined | Where - const collectionSlug = req.searchParams?.get('collectionSlug') - - if (collectionSlug) { - const collectionConfig = req.payload.collections?.[collectionSlug]?.config - - if (!collectionConfig) { - return Response.json( - { - message: `Collection with slug "${collectionSlug}" not found`, - }, - { - status: httpStatus.NOT_FOUND, - }, - ) - } - - const collectionConstraints = await buildFolderWhereConstraints({ - collectionConfig, - folderID: req.searchParams?.get('folderID') || undefined, - localeCode: typeof req?.locale === 'string' ? req.locale : undefined, - req, - search: req.searchParams?.get('search') || undefined, - sort: req.searchParams?.get('sort') || undefined, - }) - - if (collectionConstraints) { - documentWhere = collectionConstraints - } - } else { - // loop over all folder enabled collections and build constraints for each - for (const collectionSlug of Object.keys(req.payload.collections)) { - const collectionConfig = req.payload.collections[collectionSlug]?.config - - if (collectionConfig?.folders) { - const collectionConstraints = await buildFolderWhereConstraints({ - collectionConfig, - folderID: req.searchParams?.get('folderID') || undefined, - localeCode: typeof req?.locale === 'string' ? req.locale : undefined, - req, - search: req.searchParams?.get('search') || undefined, - }) - - if (collectionConstraints) { - if (!documentWhere) { - documentWhere = { or: [] } - } - if (!Array.isArray(documentWhere.or)) { - documentWhere.or = [documentWhere] - } else if (Array.isArray(documentWhere.or)) { - documentWhere.or.push(collectionConstraints) - } - } - } - } - } - - const folderCollectionConfig = - req.payload.collections?.[req.payload.config.folders.slug]?.config - - if (!folderCollectionConfig) { - return Response.json( - { - message: 'Folder collection not found', - }, - { - status: httpStatus.NOT_FOUND, - }, - ) - } - - const folderConstraints = await buildFolderWhereConstraints({ - collectionConfig: folderCollectionConfig, - folderID: req.searchParams?.get('folderID') || undefined, - localeCode: typeof req?.locale === 'string' ? req.locale : undefined, - req, - search: req.searchParams?.get('search') || undefined, - }) - - if (folderConstraints) { - folderWhere = folderConstraints - } - - const data = await getFolderData({ - collectionSlug: req.searchParams?.get('collectionSlug') || undefined, - documentWhere: documentWhere ? documentWhere : undefined, - folderID: req.searchParams?.get('folderID') || undefined, - folderWhere, - req, - }) - - return Response.json(data) - }, - method: 'get', - path: '/populate-folder-data', -} diff --git a/packages/payload/src/folders/hooks/ensureSafeCollectionsChange.ts b/packages/payload/src/folders/hooks/ensureSafeCollectionsChange.ts new file mode 100644 index 0000000000..cd8e87858f --- /dev/null +++ b/packages/payload/src/folders/hooks/ensureSafeCollectionsChange.ts @@ -0,0 +1,144 @@ +import { APIError, type CollectionBeforeValidateHook, type CollectionSlug } from '../../index.js' +import { extractID } from '../../utilities/extractID.js' +import { getTranslatedLabel } from '../../utilities/getTranslatedLabel.js' + +export const ensureSafeCollectionsChange = + ({ foldersSlug }: { foldersSlug: CollectionSlug }): CollectionBeforeValidateHook => + async ({ data, originalDoc, req }) => { + const currentFolderID = extractID(originalDoc || {}) + const parentFolderID = extractID(data?.folder || originalDoc?.folder || {}) + if (Array.isArray(data?.folderType) && data.folderType.length > 0) { + const folderType = data.folderType as string[] + const currentlyAssignedCollections: string[] | undefined = + Array.isArray(originalDoc?.folderType) && originalDoc.folderType.length > 0 + ? originalDoc.folderType + : undefined + /** + * Check if the assigned collections have changed. + * example: + * - originalAssignedCollections: ['posts', 'pages'] + * - folderType: ['posts'] + * + * The user is narrowing the types of documents that can be associated with this folder. + * If the user is only expanding the types of documents that can be associated with this folder, + * we do not need to do anything. + */ + const newCollections = currentlyAssignedCollections + ? // user is narrowing the current scope of the folder + currentlyAssignedCollections.filter((c) => !folderType.includes(c)) + : // user is adding a scope to the folder + folderType + + if (newCollections && newCollections.length > 0) { + let hasDependentDocuments = false + if (typeof currentFolderID === 'string' || typeof currentFolderID === 'number') { + const childDocumentsResult = await req.payload.findByID({ + id: currentFolderID, + collection: foldersSlug, + joins: { + documentsAndFolders: { + limit: 100_000_000, + where: { + or: [ + { + relationTo: { + in: newCollections, + }, + }, + ], + }, + }, + }, + overrideAccess: true, + req, + }) + + hasDependentDocuments = childDocumentsResult.documentsAndFolders.docs.length > 0 + } + + // matches folders that are directly related to the removed collections + let hasDependentFolders = false + if ( + !hasDependentDocuments && + (typeof currentFolderID === 'string' || typeof currentFolderID === 'number') + ) { + const childFoldersResult = await req.payload.find({ + collection: foldersSlug, + limit: 1, + req, + where: { + and: [ + { + folderType: { + in: newCollections, + }, + }, + { + folder: { + equals: currentFolderID, + }, + }, + ], + }, + }) + hasDependentFolders = childFoldersResult.totalDocs > 0 + } + + if (hasDependentDocuments || hasDependentFolders) { + const translatedLabels = newCollections.map((collectionSlug) => { + if (req.payload.collections[collectionSlug]?.config.labels.singular) { + return getTranslatedLabel( + req.payload.collections[collectionSlug]?.config.labels.plural, + req.i18n, + ) + } + return collectionSlug + }) + + throw new APIError( + `The folder "${data.name || originalDoc.name}" contains ${hasDependentDocuments ? 'documents' : 'folders'} that still belong to the following collections: ${translatedLabels.join(', ')}`, + 400, + ) + } + return data + } + } else if ( + (data?.folderType === null || + (Array.isArray(data?.folderType) && data?.folderType.length === 0)) && + parentFolderID + ) { + // attempting to set the folderType to catch-all, so we need to ensure that the parent allows this + let parentFolder + if (typeof parentFolderID === 'string' || typeof parentFolderID === 'number') { + try { + parentFolder = await req.payload.findByID({ + id: parentFolderID, + collection: foldersSlug, + overrideAccess: true, + req, + select: { + name: true, + folderType: true, + }, + user: req.user, + }) + } catch (_) { + // parent folder does not exist + } + } + + if ( + parentFolder && + parentFolder?.folderType && + Array.isArray(parentFolder.folderType) && + parentFolder.folderType.length > 0 + ) { + throw new APIError( + `The folder "${data?.name || originalDoc.name}" must have folder-type set since its parent folder ${parentFolder?.name ? `"${parentFolder?.name}" ` : ''}has a folder-type set.`, + 400, + ) + } + } + + return data + } diff --git a/packages/payload/src/folders/types.ts b/packages/payload/src/folders/types.ts index 3b7b23793e..6ec48abef1 100644 --- a/packages/payload/src/folders/types.ts +++ b/packages/payload/src/folders/types.ts @@ -10,10 +10,12 @@ export type FolderInterface = { }[] } folder?: FolderInterface | (number | string | undefined) + folderType: CollectionSlug[] name: string } & TypeWithID export type FolderBreadcrumb = { + folderType?: CollectionSlug[] id: null | number | string name: string } @@ -58,6 +60,7 @@ export type FolderOrDocument = { _folderOrDocumentTitle: string createdAt?: string folderID?: number | string + folderType: CollectionSlug[] id: number | string updatedAt?: string } & DocumentMediaData @@ -66,6 +69,7 @@ export type FolderOrDocument = { export type GetFolderDataResult = { breadcrumbs: FolderBreadcrumb[] | null documents: FolderOrDocument[] + folderAssignedCollections: CollectionSlug[] | undefined subfolders: FolderOrDocument[] } @@ -85,6 +89,12 @@ export type RootFoldersConfiguration = { }: { collection: CollectionConfig }) => CollectionConfig | Promise)[] + /** + * If true, you can scope folders to specific collections. + * + * @default true + */ + collectionSpecific?: boolean /** * Ability to view hidden fields and collections related to folders * @@ -114,9 +124,6 @@ export type CollectionFoldersConfiguration = { browseByFolder?: boolean } -type BaseFolderSortKeys = keyof Pick< - FolderOrDocument['value'], - '_folderOrDocumentTitle' | 'createdAt' | 'updatedAt' -> +type BaseFolderSortKeys = 'createdAt' | 'name' | 'updatedAt' export type FolderSortKeys = `-${BaseFolderSortKeys}` | BaseFolderSortKeys diff --git a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts index 825dbb9545..4f13d17083 100644 --- a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts +++ b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts @@ -23,6 +23,7 @@ export function formatFolderOrDocumentItem({ _folderOrDocumentTitle: String((useAsTitle && value?.[useAsTitle]) || value['id']), createdAt: value?.createdAt, folderID: value?.[folderFieldName], + folderType: value?.folderType || [], updatedAt: value?.updatedAt, } diff --git a/packages/payload/src/folders/utils/getFolderBreadcrumbs.ts b/packages/payload/src/folders/utils/getFolderBreadcrumbs.ts index c2cb4c097a..5e9c2a0102 100644 --- a/packages/payload/src/folders/utils/getFolderBreadcrumbs.ts +++ b/packages/payload/src/folders/utils/getFolderBreadcrumbs.ts @@ -27,6 +27,7 @@ export const getFolderBreadcrumbs = async ({ select: { name: true, [folderFieldName]: true, + folderType: true, }, user, where: { @@ -42,6 +43,7 @@ export const getFolderBreadcrumbs = async ({ breadcrumbs.push({ id: folder.id, name: folder.name, + folderType: folder.folderType, }) if (folder[folderFieldName]) { return getFolderBreadcrumbs({ diff --git a/packages/payload/src/folders/utils/getFolderData.ts b/packages/payload/src/folders/utils/getFolderData.ts index d5efa40ef0..6acfcf49bb 100644 --- a/packages/payload/src/folders/utils/getFolderData.ts +++ b/packages/payload/src/folders/utils/getFolderData.ts @@ -1,6 +1,6 @@ import type { CollectionSlug } from '../../index.js' import type { PayloadRequest, Where } from '../../types/index.js' -import type { GetFolderDataResult } from '../types.js' +import type { FolderOrDocument, FolderSortKeys, GetFolderDataResult } from '../types.js' import { parseDocumentID } from '../../index.js' import { getFolderBreadcrumbs } from './getFolderBreadcrumbs.js' @@ -29,6 +29,7 @@ type Args = { */ folderWhere?: Where req: PayloadRequest + sort: FolderSortKeys } /** * Query for documents, subfolders and breadcrumbs for a given folder @@ -39,6 +40,7 @@ export const getFolderData = async ({ folderID: _folderID, folderWhere, req, + sort = 'name', }: Args): Promise => { const { payload } = req @@ -65,15 +67,16 @@ export const getFolderData = async ({ parentFolderID, req, }) - const [breadcrumbs, documentsAndSubfolders] = await Promise.all([ + const [breadcrumbs, result] = await Promise.all([ breadcrumbsPromise, documentAndSubfolderPromise, ]) return { breadcrumbs, - documents: documentsAndSubfolders.documents, - subfolders: documentsAndSubfolders.subfolders, + documents: sortDocs({ docs: result.documents, sort }), + folderAssignedCollections: result.folderAssignedCollections, + subfolders: sortDocs({ docs: result.subfolders, sort }), } } else { // subfolders and documents are queried separately @@ -96,10 +99,40 @@ export const getFolderData = async ({ subfoldersPromise, documentsPromise, ]) + return { breadcrumbs, - documents, - subfolders, + documents: sortDocs({ docs: documents, sort }), + folderAssignedCollections: collectionSlug ? [collectionSlug] : undefined, + subfolders: sortDocs({ docs: subfolders, sort }), } } } + +function sortDocs({ + docs, + sort, +}: { + docs: FolderOrDocument[] + sort?: FolderSortKeys +}): FolderOrDocument[] { + if (!sort) { + return docs + } + const isDesc = typeof sort === 'string' && sort.startsWith('-') + const sortKey = (isDesc ? sort.slice(1) : sort) as FolderSortKeys + + return docs.sort((a, b) => { + let result = 0 + if (sortKey === 'name') { + result = a.value._folderOrDocumentTitle.localeCompare(b.value._folderOrDocumentTitle) + } else if (sortKey === 'createdAt') { + result = + new Date(a.value.createdAt || '').getTime() - new Date(b.value.createdAt || '').getTime() + } else if (sortKey === 'updatedAt') { + result = + new Date(a.value.updatedAt || '').getTime() - new Date(b.value.updatedAt || '').getTime() + } + return isDesc ? -result : result + }) +} diff --git a/packages/payload/src/folders/utils/getFoldersAndDocumentsFromJoin.ts b/packages/payload/src/folders/utils/getFoldersAndDocumentsFromJoin.ts index ea3ef47af9..98b40276c4 100644 --- a/packages/payload/src/folders/utils/getFoldersAndDocumentsFromJoin.ts +++ b/packages/payload/src/folders/utils/getFoldersAndDocumentsFromJoin.ts @@ -1,4 +1,5 @@ import type { PaginatedDocs } from '../../database/types.js' +import type { CollectionSlug } from '../../index.js' import type { Document, PayloadRequest, Where } from '../../types/index.js' import type { FolderOrDocument } from '../types.js' @@ -8,6 +9,7 @@ import { formatFolderOrDocumentItem } from './formatFolderOrDocumentItem.js' type QueryDocumentsAndFoldersResults = { documents: FolderOrDocument[] + folderAssignedCollections: CollectionSlug[] subfolders: FolderOrDocument[] } type QueryDocumentsAndFoldersArgs = { @@ -85,5 +87,9 @@ export async function queryDocumentsAndFoldersFromJoin({ }, ) - return results + return { + documents: results.documents, + folderAssignedCollections: subfolderDoc?.docs[0]?.folderType || [], + subfolders: results.subfolders, + } } diff --git a/packages/payload/src/utilities/combineWhereConstraints.spec.ts b/packages/payload/src/utilities/combineWhereConstraints.spec.ts new file mode 100644 index 0000000000..c852a9477b --- /dev/null +++ b/packages/payload/src/utilities/combineWhereConstraints.spec.ts @@ -0,0 +1,86 @@ +import { Where } from '../types/index.js' +import { combineWhereConstraints } from './combineWhereConstraints.js' + +describe('combineWhereConstraints', () => { + it('should merge matching constraint keys', async () => { + const constraint: Where = { + test: { + equals: 'value', + }, + } + + // should merge and queries + const andConstraint: Where = { + and: [constraint], + } + expect(combineWhereConstraints([andConstraint], 'and')).toEqual(andConstraint) + // should merge multiple and queries + expect(combineWhereConstraints([andConstraint, andConstraint], 'and')).toEqual({ + and: [constraint, constraint], + }) + + // should merge or queries + const orConstraint: Where = { + or: [constraint], + } + expect(combineWhereConstraints([orConstraint], 'or')).toEqual(orConstraint) + // should merge multiple or queries + expect(combineWhereConstraints([orConstraint, orConstraint], 'or')).toEqual({ + or: [constraint, constraint], + }) + }) + + it('should push mismatching constraints keys into `as` key', async () => { + const constraint: Where = { + test: { + equals: 'value', + }, + } + + // should push `and` into `or` key + const andConstraint: Where = { + and: [constraint], + } + expect(combineWhereConstraints([andConstraint], 'or')).toEqual({ + or: [andConstraint], + }) + + // should push `or` into `and` key + const orConstraint: Where = { + or: [constraint], + } + expect(combineWhereConstraints([orConstraint], 'and')).toEqual({ + and: [orConstraint], + }) + + // should merge `and` but push `or` into `and` key + expect(combineWhereConstraints([andConstraint, orConstraint], 'and')).toEqual({ + and: [constraint, orConstraint], + }) + }) + + it('should push non and/or constraint key into `as` key', async () => { + const basicConstraint: Where = { + test: { + equals: 'value', + }, + } + + expect(combineWhereConstraints([basicConstraint], 'and')).toEqual({ + and: [basicConstraint], + }) + expect(combineWhereConstraints([basicConstraint], 'or')).toEqual({ + or: [basicConstraint], + }) + }) + + it('should return an empty object when no constraints are provided', async () => { + expect(combineWhereConstraints([], 'and')).toEqual({}) + expect(combineWhereConstraints([], 'or')).toEqual({}) + }) + + it('should return an empty object when all constraints are empty', async () => { + expect(combineWhereConstraints([{}, {}, undefined], 'and')).toEqual({}) + expect(combineWhereConstraints([{}, {}, undefined], 'or')).toEqual({}) + }) +}) diff --git a/packages/payload/src/utilities/combineWhereConstraints.ts b/packages/payload/src/utilities/combineWhereConstraints.ts index 4363835aee..2a1b979b04 100644 --- a/packages/payload/src/utilities/combineWhereConstraints.ts +++ b/packages/payload/src/utilities/combineWhereConstraints.ts @@ -8,12 +8,27 @@ export function combineWhereConstraints( return {} } - return { - [as]: constraints.filter((constraint): constraint is Where => { + const reducedConstraints = constraints.reduce>( + (acc: Partial, constraint) => { if (constraint && typeof constraint === 'object' && Object.keys(constraint).length > 0) { - return true + if (as in constraint) { + // merge the objects under the shared key + acc[as] = [...(acc[as] as Where[]), ...(constraint[as] as Where[])] + } else { + // the constraint does not share the key + acc[as]?.push(constraint) + } } - return false - }), + + return acc + }, + { [as]: [] } satisfies Where, + ) + + if (reducedConstraints[as]?.length === 0) { + // If there are no constraints, return an empty object + return {} } + + return reducedConstraints as Where } diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index 0a9c986847..f50aa54f8f 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -134,6 +134,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'folder:browseByFolder', 'folder:deleteFolder', 'folder:folders', + 'folder:folderTypeDescription', 'folder:folderName', 'folder:itemsMovedToFolder', 'folder:itemsMovedToRoot', diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index 6a33e7ecbe..ce23c1780c 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -183,6 +183,7 @@ export const arTranslations: DefaultTranslationsObject = { deleteFolder: 'حذف المجلد', folderName: 'اسم المجلد', folders: 'مجلدات', + folderTypeDescription: 'حدد نوع المستندات التي يجب السماح بها في هذا المجلد من المجموعات.', itemHasBeenMoved: 'تم نقل {{title}} إلى {{folderName}}', itemHasBeenMovedToRoot: 'تم نقل {{title}} إلى المجلد الجذر', itemsMovedToFolder: '{{title}} تم نقله إلى {{folderName}}', diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index 59726751e4..bc2ecb7ab7 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -186,6 +186,7 @@ export const azTranslations: DefaultTranslationsObject = { deleteFolder: 'Qovluğu Sil', folderName: 'Qovluq Adı', folders: 'Qovluqlar', + folderTypeDescription: 'Bu qovluqda hangi tip kolleksiya sənədlərinə icazə verilməlidir seçin.', itemHasBeenMoved: '{{title}} {{folderName}} qovluğuna köçürüldü.', itemHasBeenMovedToRoot: '{{title}} kök qovluğa köçürüldü.', itemsMovedToFolder: '{{title}} {{folderName}} qovluğuna köçürüldü', diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index b507d73d69..308778b051 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -186,6 +186,8 @@ export const bgTranslations: DefaultTranslationsObject = { deleteFolder: 'Изтрий папка', folderName: 'Име на папка', folders: 'Папки', + folderTypeDescription: + 'Изберете кой тип документи от колекциите трябва да се допускат в тази папка.', itemHasBeenMoved: '{{title}} е преместен в {{folderName}}', itemHasBeenMovedToRoot: '{{title}} беше преместено в основната папка', itemsMovedToFolder: '{{title}} беше преместен в {{folderName}}', diff --git a/packages/translations/src/languages/bnBd.ts b/packages/translations/src/languages/bnBd.ts index 50a3462030..9a41c809f9 100644 --- a/packages/translations/src/languages/bnBd.ts +++ b/packages/translations/src/languages/bnBd.ts @@ -187,6 +187,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { deleteFolder: 'ফোল্ডার মুছুন', folderName: 'ফোল্ডারের নাম', folders: 'ফোল্ডারগুলি', + folderTypeDescription: + 'এই ফোল্ডারে কোন ধরনের সংগ্রহ নথিপত্র অনুমোদিত হওয়া উচিত তা নির্বাচন করুন।', itemHasBeenMoved: '{{title}} কে {{folderName}} এ সরানো হয়েছে', itemHasBeenMovedToRoot: '{{title}} কে মূল ফোল্ডারে সরানো হয়েছে', itemsMovedToFolder: '{{title}} কে {{folderName}} এ সরানো হয়েছে', diff --git a/packages/translations/src/languages/bnIn.ts b/packages/translations/src/languages/bnIn.ts index 97e1a90f76..8c01eb2f78 100644 --- a/packages/translations/src/languages/bnIn.ts +++ b/packages/translations/src/languages/bnIn.ts @@ -187,6 +187,8 @@ export const bnInTranslations: DefaultTranslationsObject = { deleteFolder: 'ফোল্ডার মুছুন', folderName: 'ফোল্ডারের নাম', folders: 'ফোল্ডারগুলি', + folderTypeDescription: + 'এই ফোল্ডারে কোন ধরণের কালেকশন ডকুমেন্টস অনুমতি দেওয়া উচিত তা নির্বাচন করুন।', itemHasBeenMoved: '{{title}} কে {{folderName}} এ সরানো হয়েছে', itemHasBeenMovedToRoot: '{{title}} কে মূল ফোল্ডারে সরানো হয়েছে', itemsMovedToFolder: '{{title}} কে {{folderName}} এ সরানো হয়েছে', diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index 36a9a5823c..c3c2ecead5 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -187,6 +187,8 @@ export const caTranslations: DefaultTranslationsObject = { deleteFolder: 'Esborra la carpeta', folderName: 'Nom de la Carpeta', folders: 'Carpetes', + folderTypeDescription: + 'Seleccioneu quin tipus de documents de la col·lecció haurien de ser permesos en aquesta carpeta.', itemHasBeenMoved: "{{title}} s'ha traslladat a {{folderName}}", itemHasBeenMovedToRoot: "{{title}} s'ha mogut a la carpeta arrel", itemsMovedToFolder: "{{title}} s'ha traslladat a {{folderName}}", diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index 4808651d5e..7f8304f59b 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -186,6 +186,8 @@ export const csTranslations: DefaultTranslationsObject = { deleteFolder: 'Smazat složku', folderName: 'Název složky', folders: 'Složky', + folderTypeDescription: + 'Vyberte, který typ dokumentů ze sbírky by měl být dovolen v této složce.', itemHasBeenMoved: '{{title}} bylo přesunuto do {{folderName}}', itemHasBeenMovedToRoot: '{{title}} byl přesunut do kořenové složky', itemsMovedToFolder: '{{title}} přesunuto do {{folderName}}', diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index 0f449e1c2a..ec1ef4b6ef 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -185,6 +185,8 @@ export const daTranslations: DefaultTranslationsObject = { deleteFolder: 'Slet mappe', folderName: 'Mappenavn', folders: 'Mapper', + folderTypeDescription: + 'Vælg hvilken type samling af dokumenter der bør være tilladt i denne mappe.', itemHasBeenMoved: '{{title}} er blevet flyttet til {{folderName}}', itemHasBeenMovedToRoot: '{{title}} er blevet flyttet til rodmappen', itemsMovedToFolder: '{{title}} flyttet til {{folderName}}', diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index 9c2fd199d3..ae924bb363 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -191,6 +191,8 @@ export const deTranslations: DefaultTranslationsObject = { deleteFolder: 'Ordner löschen', folderName: 'Ordnername', folders: 'Ordner', + folderTypeDescription: + 'Wählen Sie aus, welche Art von Sammlungsdokumenten in diesem Ordner zugelassen sein sollte.', itemHasBeenMoved: '{{title}} wurde in {{folderName}} verschoben.', itemHasBeenMovedToRoot: '{{title}} wurde in den Hauptordner verschoben', itemsMovedToFolder: '{{title}} wurde in {{folderName}} verschoben.', diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index 0b2d0f7694..e1600e45ae 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -186,6 +186,8 @@ export const enTranslations = { deleteFolder: 'Delete Folder', folderName: 'Folder Name', folders: 'Folders', + folderTypeDescription: + 'Select which type of collection documents should be allowed in this folder.', itemHasBeenMoved: '{{title}} has been moved to {{folderName}}', itemHasBeenMovedToRoot: '{{title}} has been moved to the root folder', itemsMovedToFolder: '{{title}} moved to {{folderName}}', diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index 311848771a..d91a45c21e 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -190,6 +190,8 @@ export const esTranslations: DefaultTranslationsObject = { deleteFolder: 'Eliminar Carpeta', folderName: 'Nombre de la Carpeta', folders: 'Carpetas', + folderTypeDescription: + 'Seleccione qué tipo de documentos de la colección se deben permitir en esta carpeta.', itemHasBeenMoved: '{{title}} se ha movido a {{folderName}}', itemHasBeenMovedToRoot: '{{title}} se ha movido a la carpeta raíz', itemsMovedToFolder: '{{title}} movido a {{folderName}}', diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index 1a72d3e422..15c77ebea4 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -185,6 +185,7 @@ export const etTranslations: DefaultTranslationsObject = { deleteFolder: 'Kustuta kaust', folderName: 'Kausta nimi', folders: 'Kaustad', + folderTypeDescription: 'Valige, millist tüüpi kogumiku dokumente peaks selles kaustas lubama.', itemHasBeenMoved: '{{title}} on teisaldatud kausta {{folderName}}', itemHasBeenMovedToRoot: '{{title}} on teisaldatud juurkausta', itemsMovedToFolder: '{{title}} viidi üle kausta {{folderName}}', diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index b0c0012eef..1284b1d942 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -184,6 +184,7 @@ export const faTranslations: DefaultTranslationsObject = { deleteFolder: 'حذف پوشه', folderName: 'نام پوشه', folders: 'پوشه‌ها', + folderTypeDescription: 'انتخاب کنید که کدام نوع اسناد مجموعه باید در این پوشه مجاز باشند.', itemHasBeenMoved: '{{title}} به {{folderName}} منتقل شده است.', itemHasBeenMovedToRoot: '{{title}} به پوشه اصلی انتقال یافته است.', itemsMovedToFolder: '{{title}} به {{folderName}} منتقل شد.', diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index 24ec5fd7b0..c5eab55fda 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -192,6 +192,8 @@ export const frTranslations: DefaultTranslationsObject = { deleteFolder: 'Supprimer le dossier', folderName: 'Nom du dossier', folders: 'Dossiers', + folderTypeDescription: + 'Sélectionnez le type de documents de collection qui devraient être autorisés dans ce dossier.', itemHasBeenMoved: '{{title}} a été déplacé vers {{folderName}}', itemHasBeenMovedToRoot: '{{title}} a été déplacé dans le dossier racine', itemsMovedToFolder: '{{title}} déplacé vers {{folderName}}', diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index 32d5ad7200..f7a8d4ff93 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -181,6 +181,7 @@ export const heTranslations: DefaultTranslationsObject = { deleteFolder: 'מחק תיקייה', folderName: 'שם תיקייה', folders: 'תיקיות', + folderTypeDescription: 'בחר איזה סוג של מסמכים מהאוסף יותרו להיות בתיקייה זו.', itemHasBeenMoved: '"{{title}}" הועבר ל- "{{folderName}}"', itemHasBeenMovedToRoot: '"{{title}}" הועבר לתיקיית השורש', itemsMovedToFolder: '{{title}} הועבר אל {{folderName}}', diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index 7271e0ac06..320217c8ef 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -187,6 +187,8 @@ export const hrTranslations: DefaultTranslationsObject = { deleteFolder: 'Izbriši mapu', folderName: 'Naziv mape', folders: 'Mape', + folderTypeDescription: + 'Odaberite koja vrsta dokumenata kolekcije treba biti dozvoljena u ovoj mapi.', itemHasBeenMoved: '{{title}} je premješten u {{folderName}}', itemHasBeenMovedToRoot: '{{title}} je premješten u korijensku mapu.', itemsMovedToFolder: '{{title}} premješteno u {{folderName}}', diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index eac7af8c04..8aaa81144b 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -188,6 +188,8 @@ export const huTranslations: DefaultTranslationsObject = { deleteFolder: 'Mappa törlése', folderName: 'Mappa neve', folders: 'Mappák', + folderTypeDescription: + 'Válassza ki, hogy milyen típusú dokumentumokat engedélyez ebben a mappában.', itemHasBeenMoved: '{{title}} át lett helyezve a {{folderName}} nevű mappába.', itemHasBeenMovedToRoot: 'A(z) {{title}} át lett helyezve a gyökérmappába.', itemsMovedToFolder: '{{title}} áthelyezve a(z) {{folderName}} mappába', diff --git a/packages/translations/src/languages/hy.ts b/packages/translations/src/languages/hy.ts index 925181f244..704b20d8e1 100644 --- a/packages/translations/src/languages/hy.ts +++ b/packages/translations/src/languages/hy.ts @@ -186,6 +186,8 @@ export const hyTranslations: DefaultTranslationsObject = { deleteFolder: 'Ջնջել թղթապանակը', folderName: 'Տեսակավորման անվանում', folders: 'Պատուհաններ', + folderTypeDescription: + 'Ընտրեք, թե որն է հավաքածուի փաստաթղթերը, որոնք պետք է թույլատրվեն այս պանակում:', itemHasBeenMoved: '{{title}}-ը տեղափոխվել է {{folderName}}-ում', itemHasBeenMovedToRoot: '«{{title}}» տեղափոխվել է արմատային պանակ։', itemsMovedToFolder: '{{title}} տեղափոխվեց {{folderName}}', diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index 55bfdbcd8e..3a51ef09b2 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -190,6 +190,8 @@ export const itTranslations: DefaultTranslationsObject = { deleteFolder: 'Elimina cartella', folderName: 'Nome Cartella', folders: 'Cartelle', + folderTypeDescription: + 'Seleziona quale tipo di documenti della collezione dovrebbero essere consentiti in questa cartella.', itemHasBeenMoved: '{{title}} è stato spostato in {{folderName}}', itemHasBeenMovedToRoot: '{{title}} è stato spostato nella cartella principale', itemsMovedToFolder: '{{title}} spostato in {{folderName}}', diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index 51d9284b96..024cf4e1fe 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -187,6 +187,8 @@ export const jaTranslations: DefaultTranslationsObject = { deleteFolder: 'フォルダを削除する', folderName: 'フォルダ名', folders: 'フォルダー', + folderTypeDescription: + 'このフォルダーに許可されるコレクションドキュメントのタイプを選択してください。', itemHasBeenMoved: '{{title}}は{{folderName}}に移動されました', itemHasBeenMovedToRoot: '{{title}}はルートフォルダに移動されました', itemsMovedToFolder: '{{title}}は{{folderName}}に移動されました', diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index e053968388..0d5af0445e 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -186,6 +186,7 @@ export const koTranslations: DefaultTranslationsObject = { deleteFolder: '폴더 삭제', folderName: '폴더 이름', folders: '폴더들', + folderTypeDescription: '이 폴더에서 어떤 유형의 컬렉션 문서가 허용되어야 하는지 선택하세요.', itemHasBeenMoved: '{{title}}는 {{folderName}}로 이동되었습니다.', itemHasBeenMovedToRoot: '{{title}}이(가) 루트 폴더로 이동되었습니다.', itemsMovedToFolder: '{{title}}이(가) {{folderName}}로 이동되었습니다.', diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index 0a9b605a10..94048058cd 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -188,6 +188,8 @@ export const ltTranslations: DefaultTranslationsObject = { deleteFolder: 'Ištrinti aplanką', folderName: 'Aplanko pavadinimas', folders: 'Aplankai', + folderTypeDescription: + 'Pasirinkite, kokio tipo rinkinio dokumentai turėtų būti leidžiami šiame aplanke.', itemHasBeenMoved: '{{title}} buvo perkeltas į {{folderName}}', itemHasBeenMovedToRoot: '{{title}} buvo perkeltas į pagrindinį katalogą', itemsMovedToFolder: '{{title}} perkeltas į {{folderName}}', diff --git a/packages/translations/src/languages/lv.ts b/packages/translations/src/languages/lv.ts index e7fb84bb10..0dcd973687 100644 --- a/packages/translations/src/languages/lv.ts +++ b/packages/translations/src/languages/lv.ts @@ -186,6 +186,8 @@ export const lvTranslations: DefaultTranslationsObject = { deleteFolder: 'Dzēst mapi', folderName: 'Mapes nosaukums', folders: 'Mapes', + folderTypeDescription: + 'Izvēlieties, kāda veida kolekcijas dokumentiem jābūt atļautiem šajā mapē.', itemHasBeenMoved: '{{title}} ir pārvietots uz {{folderName}}', itemHasBeenMovedToRoot: '{{title}} ir pārvietots uz saknes mapi', itemsMovedToFolder: '{{title}} pārvietots uz {{folderName}}', diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index c05bf18710..78c87fa725 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -187,6 +187,7 @@ export const myTranslations: DefaultTranslationsObject = { deleteFolder: 'Padam Folder', folderName: 'ဖိုင်နာမည်', folders: 'Fail', + folderTypeDescription: 'Pilih jenis dokumen koleksi yang harus diizinkan dalam folder ini.', itemHasBeenMoved: '{{title}} telah dipindahkan ke {{folderName}}', itemHasBeenMovedToRoot: '"{{title}}" က ဗဟိုဖိုလ်ဒါသို့ရွှေ့ပြီးပါပြီ။', itemsMovedToFolder: '{{title}} သို့ {{folderName}} သို့ ရွှေ့လိုက်သွားပါပယ်', diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index 90c796312c..291b85b6f7 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -186,6 +186,7 @@ export const nbTranslations: DefaultTranslationsObject = { deleteFolder: 'Slett mappe', folderName: 'Mappenavn', folders: 'Mapper', + folderTypeDescription: 'Velg hvilken type samling dokumenter som skal tillates i denne mappen.', itemHasBeenMoved: '{{title}} er flyttet til {{folderName}}', itemHasBeenMovedToRoot: '{{title}} er flyttet til rotmappen', itemsMovedToFolder: '{{title}} flyttet til {{folderName}}', diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index 316cc69508..1ba7d51a26 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -188,6 +188,8 @@ export const nlTranslations: DefaultTranslationsObject = { deleteFolder: 'Verwijder map', folderName: 'Mapnaam', folders: 'Mappen', + folderTypeDescription: + 'Selecteer welk type verzameldocumenten toegestaan zou moeten zijn in deze map.', itemHasBeenMoved: '{{title}} is verplaatst naar {{folderName}}', itemHasBeenMovedToRoot: '{{title}} is verplaatst naar de hoofdmap', itemsMovedToFolder: '{{title}} verplaatst naar {{folderName}}', diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index 2c12ba691b..1e60b6ac79 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -185,6 +185,8 @@ export const plTranslations: DefaultTranslationsObject = { deleteFolder: 'Usuń folder', folderName: 'Nazwa folderu', folders: 'Foldery', + folderTypeDescription: + 'Wybierz, które typy dokumentów z kolekcji powinny być dozwolone w tym folderze.', itemHasBeenMoved: '{{title}} został przeniesiony do {{folderName}}', itemHasBeenMovedToRoot: '{{title}} został przeniesiony do folderu głównego', itemsMovedToFolder: '{{title}} przeniesiono do {{folderName}}', diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index 8b8f95fa9a..ac01e47c00 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -186,6 +186,8 @@ export const ptTranslations: DefaultTranslationsObject = { deleteFolder: 'Apagar Pasta', folderName: 'Nome da Pasta', folders: 'Pastas', + folderTypeDescription: + 'Selecione qual tipo de documentos da coleção devem ser permitidos nesta pasta.', itemHasBeenMoved: '{{title}} foi movido para {{folderName}}', itemHasBeenMovedToRoot: '{{title}} foi movido para a pasta raiz', itemsMovedToFolder: '{{title}} movido para {{folderName}}', diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index 38825ec119..34bae916f4 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -190,6 +190,8 @@ export const roTranslations: DefaultTranslationsObject = { deleteFolder: 'Ștergeți dosarul', folderName: 'Nume dosar', folders: 'Dosare', + folderTypeDescription: + 'Selectați ce tip de documente din colecție ar trebui să fie permise în acest dosar.', itemHasBeenMoved: '{{title}} a fost mutat în {{folderName}}', itemHasBeenMovedToRoot: '{{title}} a fost mutat în dosarul rădăcină', itemsMovedToFolder: '{{title}} a fost mutat în {{folderName}}', diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index 78803c8e58..1f0701c3f4 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -187,6 +187,8 @@ export const rsTranslations: DefaultTranslationsObject = { deleteFolder: 'Obriši fasciklu', folderName: 'Ime fascikle', folders: 'Fascikle', + folderTypeDescription: + 'Odaberite koja vrsta dokumenata iz kolekcije treba biti dozvoljena u ovom folderu.', itemHasBeenMoved: '{{title}} je premješten u {{folderName}}', itemHasBeenMovedToRoot: '{{title}} je premešten u osnovni direktorijum.', itemsMovedToFolder: '{{title}} premešten u {{folderName}}', diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index 31c321059b..2ae83c93db 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -187,6 +187,8 @@ export const rsLatinTranslations: DefaultTranslationsObject = { deleteFolder: 'Obriši mapu', folderName: 'Naziv fascikle', folders: 'Fascikle', + folderTypeDescription: + 'Odaberite koja vrsta dokumenta iz kolekcije bi trebala biti dozvoljena u ovoj fascikli.', itemHasBeenMoved: '{{title}} je premesten u {{folderName}}', itemHasBeenMovedToRoot: '{{title}} je premešten u osnovnu fasciklu', itemsMovedToFolder: '{{title}} premešteno u {{folderName}}', diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index e881a1c1d4..b23b2ef9fd 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -188,6 +188,8 @@ export const ruTranslations: DefaultTranslationsObject = { deleteFolder: 'Удалить папку', folderName: 'Название папки', folders: 'Папки', + folderTypeDescription: + 'Выберите, какие типы документов коллекции должны быть разрешены в этой папке.', itemHasBeenMoved: '{{title}} был перемещен в {{folderName}}', itemHasBeenMovedToRoot: '{{title}} был перемещен в корневую папку', itemsMovedToFolder: '{{title}} перемещен в {{folderName}}', diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index 44713ae7fe..4c24b250d3 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -189,6 +189,8 @@ export const skTranslations: DefaultTranslationsObject = { deleteFolder: 'Odstrániť priečinok', folderName: 'Názov priečinka', folders: 'Priečinky', + folderTypeDescription: + 'Vyberte, ktorý typ dokumentov z kolekcie by mal byť povolený v tejto zložke.', itemHasBeenMoved: '{{title}} bol presunutý do {{folderName}}', itemHasBeenMovedToRoot: '{{title}} bol presunutý do koreňového priečinka', itemsMovedToFolder: '{{title}} presunuté do {{folderName}}', diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index 45954b2d29..02e046a58b 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -186,6 +186,8 @@ export const slTranslations: DefaultTranslationsObject = { deleteFolder: 'Izbriši mapo', folderName: 'Ime mape', folders: 'Mape', + folderTypeDescription: + 'Izberite, katere vrste dokumentov zbirke naj bodo dovoljene v tej mapi.', itemHasBeenMoved: '{{title}} je bil premaknjen v {{folderName}}', itemHasBeenMovedToRoot: '{{title}} je bil premaknjen v korensko mapo.', itemsMovedToFolder: '{{title}} premaknjeno v {{folderName}}', diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index 3a9b1e12d2..caef27df3b 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -186,6 +186,7 @@ export const svTranslations: DefaultTranslationsObject = { deleteFolder: 'Ta bort mapp', folderName: 'Mappnamn', folders: 'Mappar', + folderTypeDescription: 'Välj vilken typ av samlingsdokument som ska tillåtas i denna mapp.', itemHasBeenMoved: '{{title}} har flyttats till {{folderName}}', itemHasBeenMovedToRoot: '{{title}} har flyttats till rotmappen', itemsMovedToFolder: '{{title}} flyttad till {{folderName}}', diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index 8d53deae75..41cb9878b8 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -183,6 +183,7 @@ export const thTranslations: DefaultTranslationsObject = { deleteFolder: 'ลบโฟลเดอร์', folderName: 'ชื่อโฟลเดอร์', folders: 'โฟลเดอร์', + folderTypeDescription: 'เลือกประเภทของเอกสารคอลเลกชันที่ควรอนุญาตในโฟลเดอร์นี้', itemHasBeenMoved: '{{title}} ได้ถูกย้ายไปที่ {{folderName}}', itemHasBeenMovedToRoot: '"{{title}}" ได้ถูกย้ายไปยังโฟลเดอร์ราก', itemsMovedToFolder: '{{title}} ถูกย้ายไปยัง {{folderName}}', diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index 1630721cf9..1daaae2925 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -188,6 +188,8 @@ export const trTranslations: DefaultTranslationsObject = { deleteFolder: 'Klasörü Sil', folderName: 'Klasör Adı', folders: 'Klasörler', + folderTypeDescription: + 'Bu klasörde hangi türden koleksiyon belgelerine izin verilmesi gerektiğini seçin.', itemHasBeenMoved: '{{title}} {{folderName}} klasörüne taşındı.', itemHasBeenMovedToRoot: '{{title}} kök klasöre taşındı.', itemsMovedToFolder: "{{title}} {{folderName}}'ye taşındı.", diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index e76e29e6be..eb33c1daac 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -187,6 +187,8 @@ export const ukTranslations: DefaultTranslationsObject = { deleteFolder: 'Видалити папку', folderName: 'Назва папки', folders: 'Папки', + folderTypeDescription: + 'Виберіть, який тип документів колекції повинен бути дозволений у цій папці.', itemHasBeenMoved: '{{title}} було переміщено до {{folderName}}', itemHasBeenMovedToRoot: '{{title}} був переміщений до кореневої папки', itemsMovedToFolder: '{{title}} перенесено до {{folderName}}', diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index ae280b4fc0..7f747ef15f 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -186,6 +186,7 @@ export const viTranslations: DefaultTranslationsObject = { deleteFolder: 'Xóa Thư mục', folderName: 'Tên thư mục', folders: 'Thư mục', + folderTypeDescription: 'Chọn loại tài liệu bộ sưu tập nào nên được cho phép trong thư mục này.', itemHasBeenMoved: '{{title}} đã được chuyển đến {{folderName}}', itemHasBeenMovedToRoot: '{{title}} đã được chuyển đến thư mục gốc', itemsMovedToFolder: '{{title}} đã được di chuyển vào {{folderName}}', diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index 296612d0e1..84a477ba71 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -179,6 +179,7 @@ export const zhTranslations: DefaultTranslationsObject = { deleteFolder: '删除文件夹', folderName: '文件夹名称', folders: '文件夹', + folderTypeDescription: '在此文件夹中选择应允许哪种类型的集合文档。', itemHasBeenMoved: '{{title}}已被移至{{folderName}}', itemHasBeenMovedToRoot: '{{title}}已被移至根文件夹', itemsMovedToFolder: '{{title}}已移至{{folderName}}', diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index 6cf18d9773..e659462f6b 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -178,6 +178,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { deleteFolder: '刪除資料夾', folderName: '資料夾名稱', folders: '資料夾', + folderTypeDescription: '在此文件夾中選擇應允許的集合文件類型。', itemHasBeenMoved: '{{title}}已被移至{{folderName}}', itemHasBeenMovedToRoot: '{{title}}已被移至根文件夾', itemsMovedToFolder: '{{title}} 已移至 {{folderName}}', diff --git a/packages/ui/src/elements/FolderView/Breadcrumbs/index.tsx b/packages/ui/src/elements/FolderView/Breadcrumbs/index.tsx index 9fc54d7eb2..881a85d830 100644 --- a/packages/ui/src/elements/FolderView/Breadcrumbs/index.tsx +++ b/packages/ui/src/elements/FolderView/Breadcrumbs/index.tsx @@ -48,10 +48,11 @@ export function DroppableBreadcrumb({ children, className, onClick, -}: { children: React.ReactNode; className?: string; onClick: () => void } & Pick< - FolderBreadcrumb, - 'id' ->) { +}: { + children: React.ReactNode + className?: string + onClick: () => void +} & Pick) { const { isOver, setNodeRef } = useDroppable({ id: `folder-${id}`, data: { diff --git a/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx b/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx index df8b79865b..d0d54ecf00 100644 --- a/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx +++ b/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx @@ -107,6 +107,7 @@ export function CurrentFolderActions({ className }: Props) { diff --git a/packages/ui/src/elements/FolderView/DraggableTableRow/index.tsx b/packages/ui/src/elements/FolderView/DraggableTableRow/index.tsx index f34c70f9bd..4ded3ccd1f 100644 --- a/packages/ui/src/elements/FolderView/DraggableTableRow/index.tsx +++ b/packages/ui/src/elements/FolderView/DraggableTableRow/index.tsx @@ -69,7 +69,6 @@ export function DraggableTableRow({ ] .filter(Boolean) .join(' ')} - id={itemKey} key={itemKey} onClick={onClick} onKeyDown={onKeyDown} diff --git a/packages/ui/src/elements/FolderView/DraggableWithClick/index.scss b/packages/ui/src/elements/FolderView/DraggableWithClick/index.scss index 1fdff6a1d3..e2d7f25d64 100644 --- a/packages/ui/src/elements/FolderView/DraggableWithClick/index.scss +++ b/packages/ui/src/elements/FolderView/DraggableWithClick/index.scss @@ -1,5 +1,5 @@ @layer payload-default { - .draggable-with-click { + .draggable-with-click:not(.draggable-with-click--disabled) { user-select: none; } } diff --git a/packages/ui/src/elements/FolderView/DraggableWithClick/index.tsx b/packages/ui/src/elements/FolderView/DraggableWithClick/index.tsx index d99d2e70d9..aced1e06eb 100644 --- a/packages/ui/src/elements/FolderView/DraggableWithClick/index.tsx +++ b/packages/ui/src/elements/FolderView/DraggableWithClick/index.tsx @@ -1,5 +1,5 @@ import { useDraggable } from '@dnd-kit/core' -import React, { useRef } from 'react' +import React, { useId, useRef } from 'react' import './index.scss' @@ -9,7 +9,7 @@ type Props = { readonly as?: React.ElementType readonly children?: React.ReactNode readonly className?: string - readonly id: string + readonly disabled?: boolean readonly onClick: (e: React.MouseEvent) => void readonly onKeyDown?: (e: React.KeyboardEvent) => void readonly ref?: React.RefObject @@ -17,16 +17,17 @@ type Props = { } export const DraggableWithClick = ({ - id, as = 'div', children, className, + disabled = false, onClick, onKeyDown, ref, thresholdPixels = 3, }: Props) => { - const { attributes, listeners, setNodeRef } = useDraggable({ id }) + const id = useId() + const { attributes, listeners, setNodeRef } = useDraggable({ id, disabled }) const initialPos = useRef({ x: 0, y: 0 }) const isDragging = useRef(false) @@ -75,10 +76,15 @@ export const DraggableWithClick = ({ role="button" tabIndex={0} {...attributes} - className={`${baseClass} ${className || ''}`.trim()} - onKeyDown={onKeyDown} - onPointerDown={onClick ? handlePointerDown : undefined} + className={[baseClass, className, disabled ? `${baseClass}--disabled` : ''] + .filter(Boolean) + .join(' ')} + onKeyDown={disabled ? undefined : onKeyDown} + onPointerDown={disabled ? undefined : onClick ? handlePointerDown : undefined} ref={(node) => { + if (disabled) { + return + } setNodeRef(node) if (ref) { ref.current = node diff --git a/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.scss b/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.scss index 216c9b4c92..e4b74b8a78 100644 --- a/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.scss +++ b/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.scss @@ -22,5 +22,9 @@ align-items: center; gap: calc(var(--base) / 2); } + + .item-card-grid__title { + display: none; + } } } diff --git a/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.tsx b/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.tsx index 614182936a..ba0601174f 100644 --- a/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.tsx +++ b/packages/ui/src/elements/FolderView/Drawers/MoveToFolder/index.tsx @@ -41,6 +41,7 @@ type ActionProps = } export type MoveToFolderDrawerProps = { readonly drawerSlug: string + readonly folderAssignedCollections: CollectionSlug[] readonly folderCollectionSlug: string readonly folderFieldName: string readonly fromFolderID?: number | string @@ -86,11 +87,13 @@ function LoadFolderData(props: MoveToFolderDrawerProps) { async (folderIDToPopulate: null | number | string) => { try { const result = await getFolderResultsComponentAndData({ - activeCollectionSlugs: [props.folderCollectionSlug], browseByFolder: false, + collectionsToDisplay: [props.folderCollectionSlug], displayAs: 'grid', + // todo: should be able to pass undefined, empty array or null and get all folders. Need to look at API for this in the server function + folderAssignedCollections: props.folderAssignedCollections, folderID: folderIDToPopulate, - sort: '_folderOrDocumentTitle', + sort: 'name', }) setBreadcrumbs(result.breadcrumbs || []) @@ -107,7 +110,7 @@ function LoadFolderData(props: MoveToFolderDrawerProps) { hasLoadedRef.current = true }, - [getFolderResultsComponentAndData, props.folderCollectionSlug], + [getFolderResultsComponentAndData, props.folderAssignedCollections, props.folderCollectionSlug], ) React.useEffect(() => { @@ -167,6 +170,7 @@ function Content({ folderFieldName, folderID, FolderResultsComponent, + folderType, getSelectedItems, subfolders, } = useFolder() @@ -229,7 +233,7 @@ function Content({ }, [drawerSlug, isModalOpen, clearRouteCache, folderAddedToUnderlyingFolder]) return ( - <> +
{ closeModal(drawerSlug) @@ -298,6 +302,7 @@ function Content({ { void onCreateSuccess({ @@ -321,6 +326,7 @@ function Content({ )} - +
) } diff --git a/packages/ui/src/elements/FolderView/CollectionTypePill/index.scss b/packages/ui/src/elements/FolderView/FilterFolderTypePill/index.scss similarity index 100% rename from packages/ui/src/elements/FolderView/CollectionTypePill/index.scss rename to packages/ui/src/elements/FolderView/FilterFolderTypePill/index.scss diff --git a/packages/ui/src/elements/FolderView/CollectionTypePill/index.tsx b/packages/ui/src/elements/FolderView/FilterFolderTypePill/index.tsx similarity index 97% rename from packages/ui/src/elements/FolderView/CollectionTypePill/index.tsx rename to packages/ui/src/elements/FolderView/FilterFolderTypePill/index.tsx index 38a05855ba..7c0fbd0462 100644 --- a/packages/ui/src/elements/FolderView/CollectionTypePill/index.tsx +++ b/packages/ui/src/elements/FolderView/FilterFolderTypePill/index.tsx @@ -12,7 +12,7 @@ import './index.scss' const baseClass = 'collection-type' -export function CollectionTypePill() { +export function FilterFolderTypePill() { const { activeCollectionFolderSlugs: visibleCollectionSlugs, allCollectionFolderSlugs: folderCollectionSlugs, diff --git a/packages/ui/src/elements/FolderView/Field/index.scss b/packages/ui/src/elements/FolderView/FolderField/index.scss similarity index 100% rename from packages/ui/src/elements/FolderView/Field/index.scss rename to packages/ui/src/elements/FolderView/FolderField/index.scss diff --git a/packages/ui/src/elements/FolderView/Field/index.server.tsx b/packages/ui/src/elements/FolderView/FolderField/index.server.tsx similarity index 87% rename from packages/ui/src/elements/FolderView/Field/index.server.tsx rename to packages/ui/src/elements/FolderView/FolderField/index.server.tsx index 42da4dd077..ce051fe0b0 100644 --- a/packages/ui/src/elements/FolderView/Field/index.server.tsx +++ b/packages/ui/src/elements/FolderView/FolderField/index.server.tsx @@ -6,7 +6,7 @@ import './index.scss' const baseClass = 'folder-edit-field' -export const FolderEditField = (props: RelationshipFieldServerProps) => { +export const FolderField = (props: RelationshipFieldServerProps) => { if (props.payload.config.folders === false) { return null } diff --git a/packages/ui/src/elements/FolderView/FolderFileCard/index.scss b/packages/ui/src/elements/FolderView/FolderFileCard/index.scss index ef6dfe134b..69ec82e70a 100644 --- a/packages/ui/src/elements/FolderView/FolderFileCard/index.scss +++ b/packages/ui/src/elements/FolderView/FolderFileCard/index.scss @@ -10,6 +10,7 @@ --card-titlebar-icon-color: var(--theme-elevation-300); --card-label-color: var(--theme-text); --card-preview-icon-color: var(--theme-elevation-400); + --assigned-collections-color: var(--theme-elevation-900); position: relative; display: grid; @@ -61,6 +62,7 @@ --card-label-color: var(--theme-success-800); --card-preview-icon-color: var(--theme-success-800); --accessibility-outline: 2px solid var(--theme-success-600); + --assigned-collections-color: var(--theme-success-850); .popup:hover:not(.popup--active) { --card-icon-dots-bg-color: var(--theme-success-100); @@ -74,12 +76,25 @@ } .folder-file-card__icon-wrap .icon { - opacity: 50%; + opacity: 0.5; } .folder-file-card__preview-area .icon { opacity: 0.7; } + + .folder-file-card__preview-area .thumbnail { + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + background: var(--theme-success-150); + width: 100%; + height: 100%; + mix-blend-mode: hard-light; + } + } } &:not(.folder-file-card--selected) { @@ -104,22 +119,6 @@ } } - &__drag-handle { - position: absolute; - top: 0; - width: 100%; - height: 100%; - cursor: pointer; - background: none; - border: none; - padding: 0; - outline-offset: var(--accessibility-outline-offset); - - &:focus-visible { - outline: var(--accessibility-outline); - } - } - &__drop-area { position: absolute; top: 0; @@ -195,13 +194,15 @@ &__titlebar-area { position: relative; pointer-events: none; + display: flex; + flex-direction: column; grid-area: details; border-radius: inherit; display: grid; grid-template-columns: auto 1fr auto; gap: 1rem; align-items: center; - padding: 1rem; + padding: calc(var(--base) / 2); background-color: var(--card-bg-color); .popup { @@ -209,6 +210,10 @@ } } + &__titlebar-labels { + display: grid; + } + &__name { overflow: hidden; font-weight: bold; @@ -219,6 +224,13 @@ color: var(--card-label-color); } + &__assigned-collections { + color: var(--assigned-collections-color); + opacity: 0.5; + margin-top: 4px; + line-height: normal; + } + &__icon-wrap .icon { flex-shrink: 0; color: var(--card-titlebar-icon-color); diff --git a/packages/ui/src/elements/FolderView/FolderFileCard/index.tsx b/packages/ui/src/elements/FolderView/FolderFileCard/index.tsx index f9bb5a9cfc..cfabb80825 100644 --- a/packages/ui/src/elements/FolderView/FolderFileCard/index.tsx +++ b/packages/ui/src/elements/FolderView/FolderFileCard/index.tsx @@ -3,11 +3,14 @@ import type { FolderOrDocument } from 'payload/shared' import { useDroppable } from '@dnd-kit/core' +import { getTranslation } from '@payloadcms/translations' import React from 'react' import { DocumentIcon } from '../../../icons/Document/index.js' import { ThreeDotsIcon } from '../../../icons/ThreeDots/index.js' +import { useConfig } from '../../../providers/Config/index.js' import { useFolder } from '../../../providers/Folders/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' import { Popup } from '../../Popup/index.js' import { Thumbnail } from '../../Thumbnail/index.js' import { ColoredFolderIcon } from '../ColoredFolderIcon/index.js' @@ -19,6 +22,7 @@ const baseClass = 'folder-file-card' type Props = { readonly className?: string readonly disabled?: boolean + readonly folderType?: string[] readonly id: number | string readonly isDeleting?: boolean readonly isFocused?: boolean @@ -37,6 +41,7 @@ export function FolderFileCard({ type, className = '', disabled = false, + folderType, isDeleting = false, isFocused = false, isSelected = false, @@ -54,6 +59,7 @@ export function FolderFileCard({ data: { id, type, + folderType, }, disabled: disableDrop, }) @@ -75,7 +81,7 @@ export function FolderFileCard({ }, [isFocused]) return ( -
- {!disabled && (onClick || onKeyDown) && ( - - )} {!disableDrop ?
: null} {type === 'file' ? ( @@ -112,9 +112,14 @@ export function FolderFileCard({
{type === 'file' ? : }
-

- {title} -

+
+

+ {title} +

+ {folderType && folderType.length > 0 ? ( + + ) : null} +
{PopupActions ? ( } @@ -127,7 +132,33 @@ export function FolderFileCard({ ) : null}
-
+ + ) +} + +function AssignedCollections({ folderType }: { folderType: string[] }) { + const { config } = useConfig() + const { i18n } = useTranslation() + + const collectionsDisplayText = React.useMemo(() => { + return folderType.reduce((acc, collection) => { + const collectionConfig = config.collections?.find((c) => c.slug === collection) + if (collectionConfig) { + return [...acc, getTranslation(collectionConfig.labels.plural, i18n)] + } + return acc + }, []) + }, [folderType, config.collections, i18n]) + + return ( +

+ {collectionsDisplayText.map((label, index) => ( + + {label} + {index < folderType.length - 1 ? ', ' : ''} + + ))} +

) } @@ -138,20 +169,16 @@ type ContextCardProps = { readonly type: 'file' | 'folder' } export function ContextFolderFileCard({ type, className, index, item }: ContextCardProps) { - const { - focusedRowIndex, - isDragging, - itemKeysToMove, - onItemClick, - onItemKeyPress, - selectedItemKeys, - } = useFolder() + const { checkIfItemIsDisabled, focusedRowIndex, onItemClick, onItemKeyPress, selectedItemKeys } = + useFolder() const isSelected = selectedItemKeys.has(item.itemKey) + const isDisabled = checkIfItemIsDisabled(item) return ( { - const map: Record = {} + const map: Record = {} config.collections.forEach((collection) => { - map[collection.slug] = getTranslation(collection.labels?.singular, i18n) + map[collection.slug] = { + plural: getTranslation(collection.labels?.plural, i18n), + singular: getTranslation(collection.labels?.singular, i18n), + } }) return map }) @@ -94,7 +97,22 @@ export function FolderFileTable({ showRelationCell = true }: Props) { } if (name === 'type') { - cellValue = relationToMap[relationTo] || relationTo + cellValue = ( + <> + {relationToMap[relationTo]?.singular || relationTo} + {Array.isArray(subfolder.value?.folderType) + ? subfolder.value?.folderType.reduce((acc, slug, index) => { + if (index === 0) { + return ` — ${relationToMap[slug]?.plural || slug}` + } + if (index > 0) { + return `${acc}, ${relationToMap[slug]?.plural || slug}` + } + return acc + }, '') + : ''} + + ) } if (index === 0) { @@ -108,7 +126,7 @@ export function FolderFileTable({ showRelationCell = true }: Props) { return cellValue } })} - disabled={isDragging && selectedItemKeys?.has(itemKey)} + disabled={checkIfItemIsDisabled(subfolder)} dragData={{ id: subfolderID, type: 'folder', @@ -160,7 +178,7 @@ export function FolderFileTable({ showRelationCell = true }: Props) { } if (name === 'type') { - cellValue = relationToMap[relationTo] || relationTo + cellValue = relationToMap[relationTo]?.singular || relationTo } if (index === 0) { @@ -174,7 +192,7 @@ export function FolderFileTable({ showRelationCell = true }: Props) { return cellValue } })} - disabled={isDragging || selectedItemKeys?.has(itemKey)} + disabled={checkIfItemIsDisabled(document)} dragData={{ id: documentID, type: 'document', diff --git a/packages/ui/src/elements/FolderView/FolderTypeField/index.tsx b/packages/ui/src/elements/FolderView/FolderTypeField/index.tsx new file mode 100644 index 0000000000..2592eff529 --- /dev/null +++ b/packages/ui/src/elements/FolderView/FolderTypeField/index.tsx @@ -0,0 +1,140 @@ +import type { Option, OptionObject, SelectFieldClientProps } from 'payload' + +import React from 'react' + +import type { ReactSelectAdapterProps } from '../../ReactSelect/types.js' + +import { mergeFieldStyles } from '../../../fields/mergeFieldStyles.js' +import { formatOptions } from '../../../fields/Select/index.js' +import { SelectInput } from '../../../fields/Select/Input.js' +import { useField } from '../../../forms/useField/index.js' +import { useFolder } from '../../../providers/Folders/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' + +export const FolderTypeField = ({ + options: allSelectOptions, + ...props +}: { options: Option[] } & SelectFieldClientProps) => { + const { + field, + field: { + name, + admin: { + className, + isClearable = true, + isSortable = true, + placeholder, + } = {} as SelectFieldClientProps['field']['admin'], + hasMany = false, + label, + localized, + required, + }, + onChange: onChangeFromProps, + path: pathFromProps, + readOnly, + validate, + } = props + const { t } = useTranslation() + + const { folderType } = useFolder() + + const options = React.useMemo(() => { + if (!folderType || folderType.length === 0) { + return formatOptions(allSelectOptions) + } + return formatOptions( + allSelectOptions.filter((option) => { + if (typeof option === 'object' && option.value) { + return folderType.includes(option.value) + } + return true + }), + ) + }, [allSelectOptions, folderType]) + + const memoizedValidate = React.useCallback( + (value, validationOptions) => { + if (typeof validate === 'function') { + return validate(value, { ...validationOptions, hasMany, options, required }) + } + }, + [validate, required, hasMany, options], + ) + + const { + customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, + disabled, + path, + selectFilterOptions, + setValue, + showError, + value, + } = useField({ + potentiallyStalePath: pathFromProps, + validate: memoizedValidate, + }) + + const onChange: ReactSelectAdapterProps['onChange'] = React.useCallback( + (selectedOption: OptionObject | OptionObject[]) => { + if (!readOnly || disabled) { + let newValue: string | string[] = null + if (selectedOption && hasMany) { + if (Array.isArray(selectedOption)) { + newValue = selectedOption.map((option) => option.value) + } else { + newValue = [] + } + } else if (selectedOption && !Array.isArray(selectedOption)) { + newValue = selectedOption.value + } + + if (typeof onChangeFromProps === 'function') { + onChangeFromProps(newValue) + } + + setValue(newValue) + } + }, + [readOnly, disabled, hasMany, setValue, onChangeFromProps], + ) + + const styles = React.useMemo(() => mergeFieldStyles(field), [field]) + + return ( +
+ + selectFilterOptions?.some( + (option) => (typeof option === 'string' ? option : option.value) === value, + ) + : undefined + } + hasMany={hasMany} + isClearable={isClearable} + isSortable={isSortable} + Label={Label} + label={label} + localized={localized} + name={name} + onChange={onChange} + options={options} + path={path} + placeholder={placeholder} + readOnly={readOnly || disabled} + required={required || (Array.isArray(folderType) && folderType.length > 0)} + showError={showError} + style={styles} + value={value as string | string[]} + /> +
+ ) +} diff --git a/packages/ui/src/elements/FolderView/MoveDocToFolder/index.tsx b/packages/ui/src/elements/FolderView/MoveDocToFolder/index.tsx index 1b20c13213..2493c04f57 100644 --- a/packages/ui/src/elements/FolderView/MoveDocToFolder/index.tsx +++ b/packages/ui/src/elements/FolderView/MoveDocToFolder/index.tsx @@ -1,5 +1,6 @@ 'use client' +import type { CollectionSlug } from 'payload' import type { FolderOrDocument } from 'payload/shared' import { useModal } from '@faceless-ui/modal' @@ -16,8 +17,8 @@ import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { Button } from '../../Button/index.js' import { formatDrawerSlug, useDrawerDepth } from '../../Drawer/index.js' -import { MoveItemsToFolderDrawer } from '../Drawers/MoveToFolder/index.js' import './index.scss' +import { MoveItemsToFolderDrawer } from '../Drawers/MoveToFolder/index.js' const baseClass = 'move-doc-to-folder' @@ -151,6 +152,8 @@ export const MoveDocToFolderButton = ({ React.ReactNode - value: keyof FolderOrDocument['value'] + value: FolderSortKeys }[] = [ - { label: (t) => t('general:name'), value: '_folderOrDocumentTitle' }, + { label: (t) => t('general:name'), value: 'name' }, { label: (t) => t('general:createdAt'), value: 'createdAt' }, { label: (t) => t('general:updatedAt'), value: 'updatedAt' }, ] @@ -48,9 +48,9 @@ export function SortByPill() { const { refineFolderData, sort } = useFolder() const { t } = useTranslation() const sortDirection = sort.startsWith('-') ? 'desc' : 'asc' - const [selectedSortOption] = sortOnOptions.filter( - ({ value }) => value === (sort.startsWith('-') ? sort.slice(1) : sort), - ) + const [selectedSortOption] = + sortOnOptions.filter(({ value }) => value === (sort.startsWith('-') ? sort.slice(1) : sort)) || + sortOnOptions const [selectedOrderOption] = orderOnOptions.filter(({ value }) => value === sortDirection) return ( @@ -62,7 +62,7 @@ export function SortByPill() { ) : ( )} - {selectedSortOption.label(t)} + {selectedSortOption?.label(t)} } className={baseClass} @@ -73,12 +73,13 @@ export function SortByPill() { {sortOnOptions.map(({ label, value }) => ( { refineFolderData({ query: { - sort: value, + page: '1', + sort: sortDirection === 'desc' ? `-${value}` : value, }, updateURL: true, }) @@ -94,19 +95,23 @@ export function SortByPill() { {orderOnOptions.map(({ label, value }) => ( { - if (value === 'asc') { + if (sortDirection !== value) { refineFolderData({ query: { - sort: value === 'asc' ? `-${sort}` : sort, + page: '1', + sort: + value === 'desc' + ? `-${selectedSortOption?.value}` + : selectedSortOption?.value, }, updateURL: true, }) + close() } - close() }} > {label(t)} diff --git a/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx b/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx index ff8fe24c9a..626ff0c649 100644 --- a/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx +++ b/packages/ui/src/elements/ListHeader/TitleActions/ListCreateNewDocInFolderButton.tsx @@ -18,11 +18,13 @@ const baseClass = 'create-new-doc-in-folder' export function ListCreateNewDocInFolderButton({ buttonLabel, collectionSlugs, + folderAssignedCollections, onCreateSuccess, slugPrefix, }: { buttonLabel: string collectionSlugs: CollectionSlug[] + folderAssignedCollections: CollectionSlug[] onCreateSuccess: (args: { collectionSlug: CollectionSlug doc: Record @@ -133,6 +135,9 @@ export function ListCreateNewDocInFolderButton({ { await onCreateSuccess({ diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index e7db91cd0d..f33f1981cb 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -123,8 +123,9 @@ export { SaveDraftButton } from '../../elements/SaveDraftButton/index.js' // folder elements export { FolderProvider, useFolder } from '../../providers/Folders/index.js' export { BrowseByFolderButton } from '../../elements/FolderView/BrowseByFolderButton/index.js' -export { ItemCardGrid } from '../../elements/FolderView/ItemCardGrid/index.js' +export { FolderTypeField } from '../../elements/FolderView/FolderTypeField/index.js' export { FolderFileTable } from '../../elements/FolderView/FolderFileTable/index.js' +export { ItemCardGrid } from '../../elements/FolderView/ItemCardGrid/index.js' export { type Option as ReactSelectOption, ReactSelect } from '../../elements/ReactSelect/index.js' export { ReactSelect as Select } from '../../elements/ReactSelect/index.js' diff --git a/packages/ui/src/exports/rsc/index.ts b/packages/ui/src/exports/rsc/index.ts index 7e0b00208f..155cd026fe 100644 --- a/packages/ui/src/exports/rsc/index.ts +++ b/packages/ui/src/exports/rsc/index.ts @@ -1,7 +1,7 @@ export { FieldDiffContainer } from '../../elements/FieldDiffContainer/index.js' export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js' export { FolderTableCell } from '../../elements/FolderView/Cell/index.server.js' -export { FolderEditField } from '../../elements/FolderView/Field/index.server.js' +export { FolderField } from '../../elements/FolderView/FolderField/index.server.js' export { getHTMLDiffComponents } from '../../elements/HTMLDiff/index.js' export { File } from '../../graphics/File/index.js' export { CheckIcon } from '../../icons/Check/index.js' diff --git a/packages/ui/src/fields/Checkbox/Input.tsx b/packages/ui/src/fields/Checkbox/Input.tsx index 149712a716..43ff3c412b 100644 --- a/packages/ui/src/fields/Checkbox/Input.tsx +++ b/packages/ui/src/fields/Checkbox/Input.tsx @@ -1,7 +1,7 @@ 'use client' import type { StaticLabel } from 'payload' -import React from 'react' +import React, { useId } from 'react' import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js' import { FieldLabel } from '../../fields/FieldLabel/index.js' @@ -28,7 +28,7 @@ export type CheckboxInputProps = { export const inputBaseClass = 'checkbox-input' export const CheckboxInput: React.FC = ({ - id, + id: idFromProps, name, AfterInput, BeforeInput, @@ -43,6 +43,8 @@ export const CheckboxInput: React.FC = ({ readOnly, required, }) => { + const fallbackID = useId() + const id = idFromProps || fallbackID return (
+export const formatOptions = (options: Option[]): OptionObject[] => options.map((option) => { if (typeof option === 'object' && (option.value || option.value === '')) { return option diff --git a/packages/ui/src/providers/Folders/groupItemIDsByRelation.ts b/packages/ui/src/providers/Folders/groupItemIDsByRelation.ts new file mode 100644 index 0000000000..145a8d0935 --- /dev/null +++ b/packages/ui/src/providers/Folders/groupItemIDsByRelation.ts @@ -0,0 +1,15 @@ +import type { FolderOrDocument } from 'payload/shared' + +export function groupItemIDsByRelation(items: FolderOrDocument[]) { + return items.reduce( + (acc, item) => { + if (!acc[item.relationTo]) { + acc[item.relationTo] = [] + } + acc[item.relationTo].push(item.value.id) + + return acc + }, + {} as Record, + ) +} diff --git a/packages/ui/src/providers/Folders/index.tsx b/packages/ui/src/providers/Folders/index.tsx index 62d47883e4..2da311857e 100644 --- a/packages/ui/src/providers/Folders/index.tsx +++ b/packages/ui/src/providers/Folders/index.tsx @@ -14,7 +14,7 @@ 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' +import { groupItemIDsByRelation } from './groupItemIDsByRelation.js' type FolderQueryParams = { page?: string @@ -43,20 +43,22 @@ export type FolderContextValue = { readonly allCollectionFolderSlugs?: CollectionSlug[] allowCreateCollectionSlugs: CollectionSlug[] breadcrumbs?: FolderBreadcrumb[] + checkIfItemIsDisabled: (item: FolderOrDocument) => boolean clearSelections: () => void currentFolder?: FolderOrDocument | null documents?: FolderOrDocument[] + dragOverlayItem?: FolderOrDocument | undefined focusedRowIndex: number folderCollectionConfig: ClientCollectionConfig folderCollectionSlug: string folderFieldName: string folderID?: number | string FolderResultsComponent: React.ReactNode + folderType: CollectionSlug[] | undefined getFolderRoute: (toFolderID?: number | string) => string getSelectedItems?: () => FolderOrDocument[] isDragging: boolean itemKeysToMove?: Set - lastSelectedIndex: null | number moveToFolder: (args: { itemsToMove: FolderOrDocument[] toFolderID?: number | string @@ -69,6 +71,7 @@ export type FolderContextValue = { }) => void refineFolderData: (args: { query?: FolderQueryParams; updateURL: boolean }) => void search: string + selectedFolderCollections?: CollectionSlug[] readonly selectedItemKeys: Set setBreadcrumbs: React.Dispatch> setFocusedRowIndex: React.Dispatch> @@ -82,30 +85,33 @@ const Context = React.createContext({ allCollectionFolderSlugs: [], allowCreateCollectionSlugs: [], breadcrumbs: [], + checkIfItemIsDisabled: () => false, clearSelections: () => {}, currentFolder: null, documents: [], + dragOverlayItem: undefined, focusedRowIndex: -1, folderCollectionConfig: null, folderCollectionSlug: '', folderFieldName: 'folder', folderID: undefined, FolderResultsComponent: null, + folderType: undefined, getFolderRoute: () => '', getSelectedItems: () => [], isDragging: false, itemKeysToMove: undefined, - lastSelectedIndex: null, moveToFolder: () => Promise.resolve(undefined), onItemClick: () => undefined, onItemKeyPress: () => undefined, refineFolderData: () => undefined, search: '', + selectedFolderCollections: undefined, selectedItemKeys: new Set(), setBreadcrumbs: () => {}, setFocusedRowIndex: () => -1, setIsDragging: () => false, - sort: '_folderOrDocumentTitle', + sort: 'name', subfolders: [], }) @@ -191,7 +197,7 @@ export function FolderProvider({ FolderResultsComponent: InitialFolderResultsComponent, onItemClick: onItemClickFromProps, search, - sort = '_folderOrDocumentTitle', + sort = 'name', subfolders, }: FolderProviderProps) { const parentFolderContext = useFolder() @@ -202,6 +208,11 @@ export function FolderProvider({ const router = useRouter() const { startRouteTransition } = useRouteTransition() + const currentlySelectedIndexes = React.useRef(new Set()) + + const [selectedFolderCollections, setSelectedFolderCollections] = React.useState< + CollectionSlug[] + >([]) const [FolderResultsComponent, setFolderResultsComponent] = React.useState( InitialFolderResultsComponent || (() => null), ) @@ -221,7 +232,8 @@ export function FolderProvider({ () => new Set(), ) const [focusedRowIndex, setFocusedRowIndex] = React.useState(-1) - const [lastSelectedIndex, setLastSelectedIndex] = React.useState(null) + // This is used to determine what data to display on the drag overlay + const [dragOverlayItem, setDragOverlayItem] = React.useState() const [breadcrumbs, setBreadcrumbs] = React.useState(_breadcrumbsFromProps) const lastClickTime = React.useRef(null) @@ -230,7 +242,8 @@ export function FolderProvider({ const clearSelections = React.useCallback(() => { setFocusedRowIndex(-1) setSelectedItemKeys(new Set()) - setLastSelectedIndex(undefined) + setDragOverlayItem(undefined) + currentlySelectedIndexes.current = new Set() }, []) const mergeQuery = React.useCallback( @@ -245,6 +258,7 @@ export function FolderProvider({ ...currentQuery, ...newQuery, page, + relationTo: 'relationTo' in newQuery ? newQuery.relationTo : currentQuery?.relationTo, search: 'search' in newQuery ? newQuery.search : currentQuery?.search, sort: 'sort' in newQuery ? newQuery.sort : (currentQuery?.sort ?? undefined), } @@ -258,8 +272,11 @@ export function FolderProvider({ ({ query, updateURL }) => { if (updateURL) { const newQuery = mergeQuery(query) + startRouteTransition(() => - router.replace(`${qs.stringify(newQuery, { addQueryPrefix: true })}`), + router.replace( + `${qs.stringify({ ...newQuery, relationTo: JSON.stringify(newQuery.relationTo) }, { addQueryPrefix: true })}`, + ), ) setCurrentQuery(newQuery) @@ -301,10 +318,12 @@ export function FolderProvider({ ({ 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))) + startRouteTransition(() => { + router.push(getFolderRoute(docID)) + clearSelections() + }) } else if (collectionSlug) { // clicked on document, take the user to the documet view startRouteTransition(() => { @@ -314,8 +333,11 @@ export function FolderProvider({ path: `/collections/${collectionSlug}/${docID}`, }), ) + clearSelections() }) } + } else { + clearSelections() } if (typeof onItemClickFromProps === 'function') { @@ -335,97 +357,205 @@ export function FolderProvider({ ], ) + const handleShiftSelection = React.useCallback( + (targetIndex: number) => { + const allItems = [...subfolders, ...documents] + + // Find existing selection boundaries + const existingIndexes = allItems.reduce((acc, item, idx) => { + if (selectedItemKeys.has(item.itemKey)) { + acc.push(idx) + } + return acc + }, []) + + if (existingIndexes.length === 0) { + // No existing selection, just select target + return [targetIndex] + } + + const firstSelectedIndex = Math.min(...existingIndexes) + const lastSelectedIndex = Math.max(...existingIndexes) + const isWithinBounds = targetIndex >= firstSelectedIndex && targetIndex <= lastSelectedIndex + + // Choose anchor based on whether we're contracting or extending + let anchorIndex = targetIndex + if (isWithinBounds) { + // Contracting: if target is at a boundary, use target as anchor + // Otherwise, use furthest boundary to maintain opposite edge + if (targetIndex === firstSelectedIndex || targetIndex === lastSelectedIndex) { + anchorIndex = targetIndex + } else { + const distanceToFirst = Math.abs(targetIndex - firstSelectedIndex) + const distanceToLast = Math.abs(targetIndex - lastSelectedIndex) + anchorIndex = distanceToFirst >= distanceToLast ? firstSelectedIndex : lastSelectedIndex + } + } else { + // Extending: use closest boundary + const distanceToFirst = Math.abs(targetIndex - firstSelectedIndex) + const distanceToLast = Math.abs(targetIndex - lastSelectedIndex) + anchorIndex = distanceToFirst <= distanceToLast ? firstSelectedIndex : lastSelectedIndex + } + + // Create range from anchor to target + const startIndex = Math.min(anchorIndex, targetIndex) + const endIndex = Math.max(anchorIndex, targetIndex) + const newRangeIndexes = Array.from( + { length: endIndex - startIndex + 1 }, + (_, i) => startIndex + i, + ) + + if (isWithinBounds) { + // Contracting: replace with new range + return newRangeIndexes + } else { + // Extending: union with existing + return [...new Set([...existingIndexes, ...newRangeIndexes])] + } + }, + [subfolders, documents, selectedItemKeys], + ) + + const updateSelections = React.useCallback( + ({ indexes }: { indexes: number[] }) => { + const allItems = [...subfolders, ...documents] + const { newSelectedFolderCollections, newSelectedItemKeys } = allItems.reduce( + (acc, item, index) => { + if (indexes.includes(index)) { + acc.newSelectedItemKeys.add(item.itemKey) + if (item.relationTo === folderCollectionSlug) { + item.value.folderType?.forEach((collectionSlug) => { + if (!acc.newSelectedFolderCollections.includes(collectionSlug)) { + acc.newSelectedFolderCollections.push(collectionSlug) + } + }) + } else { + if (!acc.newSelectedFolderCollections.includes(item.relationTo)) { + acc.newSelectedFolderCollections.push(item.relationTo) + } + } + } + return acc + }, + { + newSelectedFolderCollections: [] satisfies CollectionSlug[], + newSelectedItemKeys: new Set(), + }, + ) + + setSelectedFolderCollections(newSelectedFolderCollections) + setSelectedItemKeys(newSelectedItemKeys) + }, + [documents, folderCollectionSlug, subfolders], + ) + const onItemKeyPress: FolderContextValue['onItemKeyPress'] = React.useCallback( - ({ event, index, item }) => { + ({ event, item: currentItem }) => { const { code, ctrlKey, metaKey, shiftKey } = event const isShiftPressed = shiftKey const isCtrlPressed = ctrlKey || metaKey - let newSelectedIndexes: Set | undefined = undefined + const isCurrentlySelected = selectedItemKeys.has(currentItem.itemKey) + const allItems = [...subfolders, ...documents] + const currentItemIndex = allItems.findIndex((item) => item.itemKey === currentItem.itemKey) switch (code) { - case 'ArrowDown': { - event.preventDefault() - const nextIndex = Math.min(index + 1, totalCount - 1) - setFocusedRowIndex(nextIndex) - - if (isCtrlPressed) { - break - } - - if (allowMultiSelection && isShiftPressed) { - newSelectedIndexes = getShiftSelection({ - selectFromIndex: Math.min(lastSelectedIndex, totalCount), - selectToIndex: Math.min(nextIndex, totalCount), - }) - } else { - setLastSelectedIndex(nextIndex) - newSelectedIndexes = new Set([nextIndex]) - } - break - } + case 'ArrowDown': + case 'ArrowLeft': + case 'ArrowRight': case 'ArrowUp': { event.preventDefault() - const prevIndex = Math.max(index - 1, 0) - setFocusedRowIndex(prevIndex) + + if (currentItemIndex === -1) { + break + } + + const isBackward = code === 'ArrowLeft' || code === 'ArrowUp' + const newItemIndex = isBackward ? currentItemIndex - 1 : currentItemIndex + 1 + + if (newItemIndex < 0 || newItemIndex > totalCount - 1) { + // out of bounds, keep current selection + return + } + + setFocusedRowIndex(newItemIndex) if (isCtrlPressed) { break } - if (allowMultiSelection && isShiftPressed) { - newSelectedIndexes = getShiftSelection({ - selectFromIndex: lastSelectedIndex, - selectToIndex: prevIndex, - }) - } else { - setLastSelectedIndex(prevIndex) - newSelectedIndexes = new Set([prevIndex]) + if (isShiftPressed && allowMultiSelection) { + const selectedIndexes = handleShiftSelection(newItemIndex) + updateSelections({ indexes: selectedIndexes }) + return } + + // Single selection without shift + if (!isShiftPressed) { + const newItem = allItems[newItemIndex] + setSelectedItemKeys(new Set([newItem.itemKey])) + } + break } case 'Enter': { if (selectedItemKeys.size === 1) { - newSelectedIndexes = new Set([]) setFocusedRowIndex(undefined) + navigateAfterSelection({ + collectionSlug: currentItem.relationTo, + docID: extractID(currentItem.value), + }) + return } break } case 'Escape': { - setFocusedRowIndex(undefined) - newSelectedIndexes = new Set([]) + clearSelections() break } case 'KeyA': { if (allowMultiSelection && isCtrlPressed) { event.preventDefault() setFocusedRowIndex(totalCount - 1) - newSelectedIndexes = new Set(Array.from({ length: totalCount }, (_, i) => i)) + updateSelections({ + indexes: Array.from({ length: totalCount }, (_, i) => i), + }) } break } case 'Space': { if (allowMultiSelection && isShiftPressed) { event.preventDefault() - newSelectedIndexes = getMetaSelection({ - currentSelection: newSelectedIndexes, - toggleIndex: index, + const allItems = [...subfolders, ...documents] + updateSelections({ + indexes: allItems.reduce((acc, item, idx) => { + if (item.itemKey === currentItem.itemKey) { + if (isCurrentlySelected) { + return acc + } else { + acc.push(idx) + } + } else if (selectedItemKeys.has(item.itemKey)) { + acc.push(idx) + } + return acc + }, []), }) - setLastSelectedIndex(index) } else { event.preventDefault() - newSelectedIndexes = new Set([index]) - setLastSelectedIndex(index) + updateSelections({ + indexes: isCurrentlySelected ? [] : [currentItemIndex], + }) } break } case 'Tab': { if (allowMultiSelection && isShiftPressed) { - const prevIndex = index - 1 - if (prevIndex < 0 && newSelectedIndexes?.size > 0) { + const prevIndex = currentItemIndex - 1 + if (prevIndex < 0 && selectedItemKeys?.size > 0) { setFocusedRowIndex(prevIndex) } } else { - const nextIndex = index + 1 + const nextIndex = currentItemIndex + 1 if (nextIndex === totalCount && selectedItemKeys.size > 0) { setFocusedRowIndex(totalCount - 1) } @@ -433,101 +563,100 @@ export function FolderProvider({ break } } - - 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, - documents, - lastSelectedIndex, - navigateAfterSelection, - subfolders, - totalCount, selectedItemKeys, + subfolders, + documents, + allowMultiSelection, + handleShiftSelection, + updateSelections, + navigateAfterSelection, + clearSelections, + totalCount, ], ) const onItemClick: FolderContextValue['onItemClick'] = React.useCallback( - ({ event, index, item }) => { + ({ event, item: clickedItem }) => { let doubleClicked: boolean = false const isCtrlPressed = event.ctrlKey || event.metaKey const isShiftPressed = event.shiftKey - let newSelectedIndexes: Set | undefined = undefined + const isCurrentlySelected = selectedItemKeys.has(clickedItem.itemKey) + const allItems = [...subfolders, ...documents] + const currentItemIndex = allItems.findIndex((item) => item.itemKey === clickedItem.itemKey) if (allowMultiSelection && isCtrlPressed) { - newSelectedIndexes = getMetaSelection({ - currentSelection: newSelectedIndexes, - toggleIndex: index, - }) - } else if (allowMultiSelection && isShiftPressed && lastSelectedIndex !== undefined) { - newSelectedIndexes = getShiftSelection({ - selectFromIndex: lastSelectedIndex, - selectToIndex: index, - }) + event.preventDefault() + let overlayItemKey: FolderDocumentItemKey | undefined + const indexes = allItems.reduce((acc, item, idx) => { + if (item.itemKey === clickedItem.itemKey) { + if (isCurrentlySelected && event.type !== 'pointermove') { + return acc + } else { + acc.push(idx) + overlayItemKey = item.itemKey + } + } else if (selectedItemKeys.has(item.itemKey)) { + acc.push(idx) + } + return acc + }, []) + + updateSelections({ indexes }) + + if (overlayItemKey) { + setDragOverlayItem(getItem(overlayItemKey)) + } + } else if (allowMultiSelection && isShiftPressed) { + if (currentItemIndex !== -1) { + const selectedIndexes = handleShiftSelection(currentItemIndex) + updateSelections({ indexes: selectedIndexes }) + } } else if (allowMultiSelection && event.type === 'pointermove') { // on drag start of an unselected item - if (!selectedItemKeys.has(item.itemKey)) { - newSelectedIndexes = new Set([index]) + if (!isCurrentlySelected) { + updateSelections({ + indexes: allItems.reduce((acc, item, idx) => { + if (item.itemKey === clickedItem.itemKey) { + acc.push(idx) + } + return acc + }, []), + }) } - setLastSelectedIndex(index) + setDragOverlayItem(getItem(clickedItem.itemKey)) } else { // Normal click - select single item - newSelectedIndexes = new Set([index]) const now = Date.now() - doubleClicked = now - lastClickTime.current < 400 && lastSelectedIndex === index + doubleClicked = + now - lastClickTime.current < 400 && dragOverlayItem?.itemKey === clickedItem.itemKey lastClickTime.current = now - setLastSelectedIndex(index) - } - - if (!newSelectedIndexes) { - setFocusedRowIndex(undefined) - } else { - setFocusedRowIndex(index) - } - - 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) { + updateSelections({ + indexes: isCurrentlySelected && selectedItemKeys.size === 1 ? [] : [currentItemIndex], + }) + } + setDragOverlayItem(getItem(clickedItem.itemKey)) } if (doubleClicked) { navigateAfterSelection({ - collectionSlug: item.relationTo, - docID: extractID(item.value), + collectionSlug: clickedItem.relationTo, + docID: extractID(clickedItem.value), }) } }, [ selectedItemKeys, - allowMultiSelection, - lastSelectedIndex, subfolders, documents, + allowMultiSelection, + dragOverlayItem, + getItem, + updateSelections, navigateAfterSelection, + handleShiftSelection, ], ) @@ -602,6 +731,70 @@ export function FolderProvider({ [folderID, clearSelections, folderCollectionSlug, folderFieldName, routes.api, serverURL, t], ) + const checkIfItemIsDisabled: FolderContextValue['checkIfItemIsDisabled'] = React.useCallback( + (item) => { + function folderAcceptsItem({ + item, + selectedFolderCollections, + }: { + item: FolderOrDocument + selectedFolderCollections: string[] + }): boolean { + if ( + !item.value.folderType || + (Array.isArray(item.value.folderType) && item.value.folderType.length === 0) + ) { + // Enable folder that accept all collections + return false + } + + if (selectedFolderCollections.length === 0) { + // If no collections are selected, enable folders that accept all collections + return Boolean(item.value.folderType || item.value.folderType.length > 0) + } + + // Disable folders that do not accept all of the selected collections + return selectedFolderCollections.some((slug) => { + return !item.value.folderType.includes(slug) + }) + } + + if (isDragging) { + const isSelected = selectedItemKeys.has(item.itemKey) + if (isSelected) { + return true + } else if (item.relationTo === folderCollectionSlug) { + return folderAcceptsItem({ item, selectedFolderCollections }) + } else { + // Non folder items are disabled on drag + return true + } + } else if (parentFolderContext?.selectedItemKeys?.size) { + // Disable selected items from being navigated to in move to drawer + if (parentFolderContext.selectedItemKeys.has(item.itemKey)) { + return true + } + // Moving items to folder + if (item.relationTo === folderCollectionSlug) { + return folderAcceptsItem({ + item, + selectedFolderCollections: parentFolderContext.selectedFolderCollections, + }) + } + // If the item is not a folder, it is disabled on move + return true + } + }, + [ + selectedFolderCollections, + isDragging, + selectedItemKeys, + folderCollectionSlug, + parentFolderContext?.selectedFolderCollections, + parentFolderContext?.selectedItemKeys, + ], + ) + // If a new component is provided, update the state so children can re-render with the new component React.useEffect(() => { if (InitialFolderResultsComponent) { @@ -616,33 +809,37 @@ export function FolderProvider({ allCollectionFolderSlugs, allowCreateCollectionSlugs, breadcrumbs, + checkIfItemIsDisabled, clearSelections, - currentFolder: breadcrumbs?.[0]?.id - ? formatFolderOrDocumentItem({ - folderFieldName, - isUpload: false, - relationTo: folderCollectionSlug, - useAsTitle: folderCollectionConfig.admin.useAsTitle, - value: breadcrumbs[breadcrumbs.length - 1], - }) - : null, + currentFolder: + breadcrumbs?.[breadcrumbs.length - 1]?.id !== undefined + ? formatFolderOrDocumentItem({ + folderFieldName, + isUpload: false, + relationTo: folderCollectionSlug, + useAsTitle: folderCollectionConfig.admin.useAsTitle, + value: breadcrumbs[breadcrumbs.length - 1], + }) + : null, documents, + dragOverlayItem, focusedRowIndex, folderCollectionConfig, folderCollectionSlug, folderFieldName, folderID, FolderResultsComponent, + folderType: breadcrumbs?.[breadcrumbs.length - 1]?.folderType, getFolderRoute, getSelectedItems, isDragging, itemKeysToMove: parentFolderContext.selectedItemKeys, - lastSelectedIndex, moveToFolder, onItemClick, onItemKeyPress, refineFolderData, search, + selectedFolderCollections, selectedItemKeys, setBreadcrumbs, setFocusedRowIndex, diff --git a/packages/ui/src/providers/Folders/selection.ts b/packages/ui/src/providers/Folders/selection.ts deleted file mode 100644 index b3b1f932ca..0000000000 --- a/packages/ui/src/providers/Folders/selection.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { FolderOrDocument } from 'payload/shared' - -export function getShiftSelection({ - selectFromIndex, - selectToIndex, -}: { - selectFromIndex: number - selectToIndex: number -}): Set { - if (selectFromIndex === null || selectFromIndex === undefined) { - return new Set([selectToIndex]) - } - - const start = Math.min(selectToIndex, selectFromIndex) - const end = Math.max(selectToIndex, selectFromIndex) - const rangeSelection = new Set( - Array.from({ length: Math.max(start, end) + 1 }, (_, i) => i).filter((index) => { - return index >= start && index <= end - }), - ) - return rangeSelection -} - -export function getMetaSelection({ - currentSelection, - toggleIndex, -}: { - currentSelection: Set - toggleIndex: number -}): Set { - const newSelection = new Set(currentSelection) - if (newSelection.has(toggleIndex)) { - newSelection.delete(toggleIndex) - } else { - newSelection.add(toggleIndex) - } - return newSelection -} - -export function groupItemIDsByRelation(items: FolderOrDocument[]) { - return items.reduce( - (acc, item) => { - if (!acc[item.relationTo]) { - acc[item.relationTo] = [] - } - acc[item.relationTo].push(item.value.id) - - return acc - }, - {} as Record, - ) -} diff --git a/packages/ui/src/utilities/getFolderResultsComponentAndData.tsx b/packages/ui/src/utilities/getFolderResultsComponentAndData.tsx index 30c6830de0..69378293b6 100644 --- a/packages/ui/src/utilities/getFolderResultsComponentAndData.tsx +++ b/packages/ui/src/utilities/getFolderResultsComponentAndData.tsx @@ -8,7 +8,7 @@ import type { import type { FolderBreadcrumb, FolderOrDocument } from 'payload/shared' import { APIError, formatErrors, getFolderData } from 'payload' -import { buildFolderWhereConstraints } from 'payload/shared' +import { buildFolderWhereConstraints, combineWhereConstraints } from 'payload/shared' import { FolderFileTable, @@ -19,6 +19,7 @@ import { type GetFolderResultsComponentAndDataResult = { breadcrumbs?: FolderBreadcrumb[] documents?: FolderOrDocument[] + folderAssignedCollections?: CollectionSlug[] FolderResultsComponent: React.ReactNode subfolders?: FolderOrDocument[] } @@ -45,17 +46,10 @@ export const getFolderResultsComponentAndDataHandler: ServerFunction< 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 - } + req.payload.logger.error({ + err, + msg: `There was an error getting the folder results component and data`, + }) return formatErrors(err) } @@ -64,16 +58,12 @@ export const getFolderResultsComponentAndDataHandler: ServerFunction< /** * 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, + browseByFolder = false, + collectionsToDisplay: activeCollectionSlugs, displayAs, + folderAssignedCollections, folderID = undefined, req, sort, @@ -84,9 +74,17 @@ export const getFolderResultsComponentAndData = async ({ throw new APIError('Folders are not enabled in the configuration.') } + const emptyQuery = { + id: { + exists: false, + }, + } + let collectionSlug: CollectionSlug | undefined = undefined - let documentWhere: undefined | Where = undefined - let folderWhere: undefined | Where = undefined + let documentWhere: undefined | Where = + Array.isArray(activeCollectionSlugs) && !activeCollectionSlugs.length ? emptyQuery : undefined + let folderWhere: undefined | Where = + Array.isArray(activeCollectionSlugs) && !activeCollectionSlugs.length ? emptyQuery : undefined // todo(perf): - collect promises and resolve them in parallel for (const activeCollectionSlug of activeCollectionSlugs) { @@ -103,6 +101,39 @@ export const getFolderResultsComponentAndData = async ({ if (folderCollectionConstraints) { folderWhere = folderCollectionConstraints } + + folderWhere = combineWhereConstraints([ + folderWhere, + Array.isArray(folderAssignedCollections) && + folderAssignedCollections.length && + payload.config.folders.collectionSpecific + ? { + or: [ + { + folderType: { + in: folderAssignedCollections, + }, + }, + // if the folderType is not set, it means it accepts all collections and should appear in the results + { + folderType: { + exists: false, + }, + }, + { + folderType: { + equals: [], + }, + }, + { + folderType: { + equals: null, + }, + }, + ], + } + : undefined, + ]) } else if ((browseByFolder && folderID) || !browseByFolder) { if (!browseByFolder) { collectionSlug = activeCollectionSlug @@ -135,6 +166,7 @@ export const getFolderResultsComponentAndData = async ({ folderID, folderWhere, req, + sort, }) let FolderResultsComponent = null @@ -167,6 +199,7 @@ export const getFolderResultsComponentAndData = async ({ return { breadcrumbs: folderData.breadcrumbs, documents: folderData.documents, + folderAssignedCollections: folderData.folderAssignedCollections, FolderResultsComponent, subfolders: folderData.subfolders, } diff --git a/packages/ui/src/views/BrowseByFolder/index.tsx b/packages/ui/src/views/BrowseByFolder/index.tsx index 3e9771e6bb..f2688189e7 100644 --- a/packages/ui/src/views/BrowseByFolder/index.tsx +++ b/packages/ui/src/views/BrowseByFolder/index.tsx @@ -9,10 +9,10 @@ import { useRouter } from 'next/navigation.js' import React, { Fragment } from 'react' import { DroppableBreadcrumb } from '../../elements/FolderView/Breadcrumbs/index.js' -import { CollectionTypePill } from '../../elements/FolderView/CollectionTypePill/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 { FilterFolderTypePill } from '../../elements/FolderView/FilterFolderTypePill/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' @@ -92,6 +92,7 @@ function BrowseByFolderViewInContext(props: BrowseByFolderViewInContextProps) { Description, disableBulkDelete, disableBulkEdit, + folderAssignedCollections, viewPreference, } = props @@ -111,11 +112,12 @@ function BrowseByFolderViewInContext(props: BrowseByFolderViewInContextProps) { allowCreateCollectionSlugs, breadcrumbs, documents, + dragOverlayItem, folderCollectionConfig, folderID, + folderType, getFolderRoute, getSelectedItems, - lastSelectedIndex, moveToFolder, refineFolderData, search, @@ -236,6 +238,10 @@ function BrowseByFolderViewInContext(props: BrowseByFolderViewInContextProps) { } }, [breadcrumbs, drawerDepth, getFolderRoute, router, setStepNav, startRouteTransition, t]) + const nonFolderCollectionSlugs = allowCreateCollectionSlugs.filter( + (slug) => slug !== folderCollectionConfig.slug, + ) + return ( @@ -248,6 +254,7 @@ function BrowseByFolderViewInContext(props: BrowseByFolderViewInContextProps) { ), @@ -259,6 +266,7 @@ function BrowseByFolderViewInContext(props: BrowseByFolderViewInContextProps) { , - folderID && , + folderID && , ), - folderID && - allowCreateCollectionSlugs.filter((slug) => slug !== folderCollectionConfig.slug) - .length > 0 && ( - slug !== folderCollectionConfig.slug, - )} - key="create-document" - onCreateSuccess={clearRouteCache} - slugPrefix="create-document--no-results" - /> - ), + folderID && nonFolderCollectionSlugs.length > 0 && ( + + ), ].filter(Boolean)} Message={

@@ -347,11 +353,9 @@ function BrowseByFolderViewInContext(props: BrowseByFolderViewInContextProps) { {AfterFolderList}

- + {selectedItemKeys.size > 0 && dragOverlayItem && ( + + )} ) } diff --git a/packages/ui/src/views/CollectionFolder/ListSelection/index.tsx b/packages/ui/src/views/CollectionFolder/ListSelection/index.tsx index cef1fffdf0..3ab40ab3b5 100644 --- a/packages/ui/src/views/CollectionFolder/ListSelection/index.tsx +++ b/packages/ui/src/views/CollectionFolder/ListSelection/index.tsx @@ -1,5 +1,7 @@ 'use client' +import type { CollectionSlug } from 'payload' + import { useModal } from '@faceless-ui/modal' import { extractID } from 'payload/shared' import React, { Fragment } from 'react' @@ -30,16 +32,17 @@ type GroupedSelections = { export type ListSelectionProps = { disableBulkDelete?: boolean disableBulkEdit?: boolean + folderAssignedCollections: CollectionSlug[] } export const ListSelection: React.FC = ({ disableBulkDelete, disableBulkEdit, + folderAssignedCollections, }) => { const { clearSelections, currentFolder, - folderCollectionConfig, folderCollectionSlug, folderFieldName, folderID, @@ -135,6 +138,7 @@ export const ListSelection: React.FC = ({ = ({ ) } + clearRouteCache() closeModal(moveToFolderDrawerSlug) }} /> diff --git a/packages/ui/src/views/CollectionFolder/index.tsx b/packages/ui/src/views/CollectionFolder/index.tsx index 98c58b60b7..12cc5eefcb 100644 --- a/packages/ui/src/views/CollectionFolder/index.tsx +++ b/packages/ui/src/views/CollectionFolder/index.tsx @@ -107,11 +107,12 @@ function CollectionFolderViewInContext(props: CollectionFolderViewInContextProps allowCreateCollectionSlugs, breadcrumbs, documents, + dragOverlayItem, folderCollectionConfig, folderCollectionSlug, FolderResultsComponent, + folderType, getSelectedItems, - lastSelectedIndex, moveToFolder, refineFolderData, selectedItemKeys, @@ -265,6 +266,9 @@ function CollectionFolderViewInContext(props: CollectionFolderViewInContextProps ), @@ -284,6 +288,9 @@ function CollectionFolderViewInContext(props: CollectionFolderViewInContextProps {AfterFolderList} - + {selectedItemKeys.size > 0 && dragOverlayItem && ( + + )} ) } diff --git a/test/folders/e2e.spec.ts b/test/folders/e2e.spec.ts index aaa1a61aee..2e433de469 100644 --- a/test/folders/e2e.spec.ts +++ b/test/folders/e2e.spec.ts @@ -1,19 +1,26 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' -import { reInitializeDB } from 'helpers/reInitializeDB.js' import * as path from 'path' import { fileURLToPath } from 'url' import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' +import { + getSelectInputOptions, + getSelectInputValue, + openSelectMenu, +} from '../helpers/e2e/selectInput.js' +import { applyBrowseByFolderTypeFilter } from '../helpers/folders/applyBrowseByFolderTypeFilter.js' import { clickFolderCard } from '../helpers/folders/clickFolderCard.js' import { createFolder } from '../helpers/folders/createFolder.js' +import { createFolderDoc } from '../helpers/folders/createFolderDoc.js' import { createFolderFromDoc } from '../helpers/folders/createFolderFromDoc.js' import { expectNoResultsAndCreateFolderButton } from '../helpers/folders/expectNoResultsAndCreateFolderButton.js' import { selectFolderAndConfirmMove } from '../helpers/folders/selectFolderAndConfirmMove.js' import { selectFolderAndConfirmMoveFromList } from '../helpers/folders/selectFolderAndConfirmMoveFromList.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../helpers/reInitializeDB.js' import { TEST_TIMEOUT_LONG } from '../playwright.config.js' import { omittedFromBrowseBySlug, postSlug } from './shared.js' @@ -93,16 +100,15 @@ test.describe('Folders', () => { await page.goto(`${serverURL}/admin/browse-by-folder`) await createFolder({ folderName: 'Test Folder', page }) await clickFolderCard({ folderName: 'Test Folder', page }) - const renameButton = page.locator('.list-selection__actions button', { - hasText: 'Rename', + const editFolderDocButton = page.locator('.list-selection__actions button', { + hasText: 'Edit', + }) + await editFolderDocButton.click() + await createFolderDoc({ + page, + folderName: 'Renamed Folder', + folderType: ['Posts'], }) - await renameButton.click() - const folderNameInput = page.locator('input[id="field-name"]') - await folderNameInput.fill('Renamed Folder') - const applyChangesButton = page.locator( - 'dialog#rename-folder--list button[aria-label="Apply Changes"]', - ) - await applyChangesButton.click() await expect(page.locator('.payload-toast-container')).toContainText('successfully') const renamedFolderCard = page .locator('.folder-file-card__name', { @@ -165,16 +171,12 @@ test.describe('Folders', () => { hasText: 'Move', }) await moveButton.click() - const destinationFolder = page - .locator('dialog#move-to-folder--list .folder-file-card') - .filter({ - has: page.locator('.folder-file-card__name', { hasText: 'Move Into This Folder' }), - }) - .first() - const destinationFolderButton = destinationFolder.locator( - 'div[role="button"].folder-file-card__drag-handle', - ) - await destinationFolderButton.click() + await clickFolderCard({ + folderName: 'Move Into This Folder', + page, + doubleClick: true, + rootLocator: page.locator('dialog#move-to-folder--list'), + }) const selectButton = page.locator( 'dialog#move-to-folder--list button[aria-label="Apply Changes"]', ) @@ -193,7 +195,11 @@ test.describe('Folders', () => { // this test currently fails in postgres test('should create new document from folder', async () => { await page.goto(`${serverURL}/admin/browse-by-folder`) - await createFolder({ folderName: 'Create New Here', page }) + await createFolder({ + folderName: 'Create New Here', + page, + folderType: ['Posts', 'Drafts'], + }) await clickFolderCard({ folderName: 'Create New Here', page, doubleClick: true }) const createDocButton = page.locator('.create-new-doc-in-folder__popup-button', { hasText: 'Create document', @@ -231,22 +237,12 @@ test.describe('Folders', () => { await expect(createFolderButton).toBeVisible() await createFolderButton.click() - const drawerHeader = page.locator( - 'dialog#create-folder--no-results-new-folder-drawer h1.drawerHeader__title', - ) - await expect(drawerHeader).toHaveText('New Folder') + await createFolderDoc({ + page, + folderName: 'Nested Folder', + folderType: ['Posts'], + }) - const titleField = page.locator( - 'dialog#create-folder--no-results-new-folder-drawer input[id="field-name"]', - ) - await titleField.fill('Nested Folder') - const createButton = page - .locator( - 'dialog#create-folder--no-results-new-folder-drawer button[aria-label="Apply Changes"]', - ) - .filter({ hasText: 'Create' }) - .first() - await createButton.click() await expect(page.locator('.payload-toast-container')).toContainText('successfully') await expect(page.locator('dialog#create-folder--no-results-new-folder-drawer')).toBeHidden() }) @@ -296,12 +292,11 @@ test.describe('Folders', () => { await createNewDropdown.click() const createFolderButton = page.locator('.popup-button-list__button').first() await createFolderButton.click() - const folderNameInput = page.locator('input[id="field-name"]') - await folderNameInput.fill('Nested Folder') - const createButton = page - .locator('.drawerHeader button[aria-label="Apply Changes"]') - .filter({ hasText: 'Create' }) - await createButton.click() + await createFolderDoc({ + page, + folderName: 'Nested Folder', + folderType: ['Posts'], + }) await expect(page.locator('.folder-file-card__name')).toHaveText('Nested Folder') await createNewDropdown.click() @@ -314,18 +309,28 @@ test.describe('Folders', () => { await saveButton.click() await expect(page.locator('.payload-toast-container')).toContainText('successfully') - const typeButton = page.locator('.popup-button', { hasText: 'Type' }) - await typeButton.click() - const folderCheckbox = page.locator('.checkbox-popup__options .checkbox-input__input').first() - await folderCheckbox.click() + // should filter out folders and only show posts + await applyBrowseByFolderTypeFilter({ + page, + type: { label: 'Folders', value: 'payload-folders' }, + on: false, + }) const folderGroup = page.locator('.item-card-grid__title', { hasText: 'Folders' }) const postGroup = page.locator('.item-card-grid__title', { hasText: 'Documents' }) await expect(folderGroup).toBeHidden() await expect(postGroup).toBeVisible() - await folderCheckbox.click() - const postCheckbox = page.locator('.checkbox-popup__options .checkbox-input__input').nth(1) - await postCheckbox.click() + // should filter out posts and only show folders + await applyBrowseByFolderTypeFilter({ + page, + type: { label: 'Folders', value: 'payload-folders' }, + on: true, + }) + await applyBrowseByFolderTypeFilter({ + page, + type: { label: 'Posts', value: 'posts' }, + on: false, + }) await expect(folderGroup).toBeVisible() await expect(postGroup).toBeHidden() @@ -389,7 +394,6 @@ test.describe('Folders', () => { test('should resolve folder pills and not get stuck as Loading...', async () => { await selectFolderAndConfirmMoveFromList({ folderName: 'Move Into This Folder', page }) const folderPill = page.locator('tbody .row-1 .move-doc-to-folder') - await page.reload() await expect(folderPill).not.toHaveText('Loading...') }) test('should show updated folder pill after folder change', async () => { @@ -402,10 +406,16 @@ test.describe('Folders', () => { const folderPill = page.locator('tbody .row-1 .move-doc-to-folder') await selectFolderAndConfirmMoveFromList({ folderName: 'Move Into This Folder', page }) await expect(folderPill).toHaveText('Move Into This Folder') - await page.reload() await folderPill.click() - const folderBreadcrumb = page.locator('.folderBreadcrumbs__crumb-item', { hasText: 'Folder' }) - await folderBreadcrumb.click() + const drawerLocator = page.locator('dialog .move-folder-drawer') + await drawerLocator + .locator('.droppable-button.folderBreadcrumbs__crumb-item', { + hasText: 'Folder', + }) + .click() + await expect( + drawerLocator.locator('.folder-file-card__name', { hasText: 'Move Into This Folder' }), + ).toBeVisible() await selectFolderAndConfirmMove({ page }) await expect(folderPill).toHaveText('No Folder') }) @@ -418,14 +428,11 @@ test.describe('Folders', () => { await createDropdown.click() const createFolderButton = page.locator('.popup-button-list__button', { hasText: 'Folder' }) await createFolderButton.click() - const drawerHeader = page.locator('.drawerHeader__title', { hasText: 'New Folder' }) - await expect(drawerHeader).toBeVisible() - const folderNameInput = page.locator('input[id="field-name"]') - await folderNameInput.fill('New Folder From Collection') - const createButton = page - .locator('.drawerHeader button[aria-label="Apply Changes"]') - .filter({ hasText: 'Create' }) - await createButton.click() + await createFolderDoc({ + page, + folderName: 'New Folder From Collection', + folderType: ['Posts'], + }) await expect(page.locator('.payload-toast-container')).toContainText('successfully') }) }) @@ -470,6 +477,58 @@ test.describe('Folders', () => { }) }) + test.describe('Collection with browse by folders disabled', () => { + test('should not show omitted collection documents in browse by folder view', async () => { + await page.goto(OmittedFromBrowseBy.byFolder) + const folderName = 'Folder without omitted Docs' + await page.goto(OmittedFromBrowseBy.byFolder) + await createFolder({ + folderName, + page, + fromDropdown: true, + folderType: ['Omitted From Browse By', 'Posts'], + }) + + // create document + await page.goto(OmittedFromBrowseBy.create) + const titleInput = page.locator('input[name="title"]') + await titleInput.fill('Omitted Doc') + await saveDocAndAssert(page) + + // assign to folder + const folderPill = page.locator('.doc-controls .move-doc-to-folder', { hasText: 'No Folder' }) + await folderPill.click() + await clickFolderCard({ folderName, page }) + const selectButton = page + .locator('button[aria-label="Apply Changes"]') + .filter({ hasText: 'Select' }) + await selectButton.click() + await saveDocAndAssert(page) + + // go to browse by folder view + await page.goto(`${serverURL}/admin/browse-by-folder`) + await clickFolderCard({ folderName, page, doubleClick: true }) + + // folder should be empty + await expectNoResultsAndCreateFolderButton({ page }) + }) + + test('should not show collection type in browse by folder view', async () => { + const folderName = 'omitted collection pill test folder' + await page.goto(`${serverURL}/admin/browse-by-folder`) + await createFolder({ folderName, page }) + await clickFolderCard({ folderName, page, doubleClick: true }) + + await page.locator('button:has(.collection-type__count)').click() + + await expect( + page.locator('.checkbox-input .field-label', { + hasText: 'Omitted From Browse By', + }), + ).toBeHidden() + }) + }) + test.describe('Multiple select options', () => { test.beforeEach(async () => { await page.goto(`${serverURL}/admin/browse-by-folder`) @@ -545,48 +604,140 @@ test.describe('Folders', () => { }) }) - test.describe('Collection with browse by folders disabled', () => { - const folderName = 'Folder without omitted Docs' - test('should not show omitted collection documents in browse by folder view', async () => { - await page.goto(OmittedFromBrowseBy.byFolder) - await createFolder({ folderName, page, fromDropdown: true }) - - // create document - await page.goto(OmittedFromBrowseBy.create) - const titleInput = page.locator('input[name="title"]') - await titleInput.fill('Omitted Doc') - await saveDocAndAssert(page) - - // assign to folder - const folderPill = page.locator('.doc-controls .move-doc-to-folder', { hasText: 'No Folder' }) - await folderPill.click() - await clickFolderCard({ folderName, page }) - const selectButton = page - .locator('button[aria-label="Apply Changes"]') - .filter({ hasText: 'Select' }) - await selectButton.click() - - // go to browse by folder view + test.describe('should inherit folderType select values from parent folder', () => { + test('should scope folderType select options for: scoped > child folder', async () => { await page.goto(`${serverURL}/admin/browse-by-folder`) - await clickFolderCard({ folderName, page, doubleClick: true }) + await createFolder({ folderName: 'Posts and Media', page, folderType: ['Posts', 'Media'] }) + await clickFolderCard({ folderName: 'Posts and Media', page, doubleClick: true }) - // folder should be empty - await expectNoResultsAndCreateFolderButton({ page }) + const createNewDropdown = page.locator('.create-new-doc-in-folder__popup-button', { + hasText: 'Create New', + }) + await createNewDropdown.click() + const createFolderButton = page.locator( + '.list-header__title-actions .popup-button-list__button', + { hasText: 'Folder' }, + ) + await createFolderButton.click() + + const drawer = page.locator('dialog .collection-edit--payload-folders') + const titleInput = drawer.locator('#field-name') + await titleInput.fill('Should only allow Posts and Media') + const selectLocator = drawer.locator('#field-folderType') + await expect(selectLocator).toBeVisible() + + // should prefill with Posts and Media + await expect + .poll(async () => { + const options = await getSelectInputValue({ selectLocator, multiSelect: true }) + return options.sort() + }) + .toEqual(['Posts', 'Media'].sort()) + + // should have no more select options available + await openSelectMenu({ selectLocator }) + await expect( + selectLocator.locator('.rs__menu-notice', { hasText: 'No options' }), + ).toBeVisible() }) - test('should not show collection type in browse by folder view', async () => { - const folderName = 'omitted collection pill test folder' + test('should scope folderType select options for: unscoped > scoped > child folder', async () => { await page.goto(`${serverURL}/admin/browse-by-folder`) - await createFolder({ folderName, page }) - await clickFolderCard({ folderName, page, doubleClick: true }) - await page.locator('button:has(.collection-type__count)').click() + // create an unscoped parent folder + await createFolder({ folderName: 'All collections', page, folderType: [] }) + await clickFolderCard({ folderName: 'All collections', page, doubleClick: true }) + + // create a scoped child folder + await createFolder({ + folderName: 'Posts and Media', + page, + folderType: ['Posts', 'Media'], + fromDropdown: true, + }) + await clickFolderCard({ folderName: 'Posts and Media', page, doubleClick: true }) await expect( - page.locator('.checkbox-input .field-label', { - hasText: 'Omitted From Browse By', + page.locator('.step-nav', { + hasText: 'Posts and Media', }), - ).toBeHidden() + ).toBeVisible() + + const titleActionsLocator = page.locator('.list-header__title-actions') + await expect(titleActionsLocator).toBeVisible() + const folderDropdown = page.locator( + '.list-header__title-actions .create-new-doc-in-folder__action-popup', + { + hasText: 'Create', + }, + ) + await expect(folderDropdown).toBeVisible() + await folderDropdown.click() + const createFolderButton = page.locator( + '.list-header__title-actions .popup-button-list__button', + { + hasText: 'Folder', + }, + ) + await createFolderButton.click() + + const drawer = page.locator('dialog .collection-edit--payload-folders') + const titleInput = drawer.locator('#field-name') + await titleInput.fill('Should only allow posts and media') + const selectLocator = drawer.locator('#field-folderType') + await expect(selectLocator).toBeVisible() + + // should not prefill with any options + await expect + .poll(async () => { + const options = await getSelectInputValue({ selectLocator, multiSelect: true }) + return options.sort() + }) + .toEqual(['Posts', 'Media'].sort()) + + // should have no more select options available + await openSelectMenu({ selectLocator }) + await expect( + selectLocator.locator('.rs__menu-notice', { hasText: 'No options' }), + ).toBeVisible() + }) + + test('should not scope child folder of an unscoped parent folder', async () => { + await page.goto(`${serverURL}/admin/browse-by-folder`) + await createFolder({ folderName: 'All collections', page, folderType: [] }) + await clickFolderCard({ folderName: 'All collections', page, doubleClick: true }) + + const createNewDropdown = page.locator('.create-new-doc-in-folder__popup-button', { + hasText: 'Create New', + }) + await createNewDropdown.click() + const createFolderButton = page.locator( + '.list-header__title-actions .popup-button-list__button', + { hasText: 'Folder' }, + ) + await createFolderButton.click() + + const drawer = page.locator('dialog .collection-edit--payload-folders') + const titleInput = drawer.locator('#field-name') + await titleInput.fill('Should allow all collections') + const selectLocator = drawer.locator('#field-folderType') + await expect(selectLocator).toBeVisible() + + // should not prefill with any options + await expect + .poll(async () => { + const options = await getSelectInputValue({ selectLocator, multiSelect: true }) + return options + }) + .toEqual([]) + + // should have many options + await expect + .poll(async () => { + const options = await getSelectInputOptions({ selectLocator }) + return options.length + }) + .toBeGreaterThan(4) }) }) diff --git a/test/folders/int.spec.ts b/test/folders/int.spec.ts index 17afb242a8..6f10a6e733 100644 --- a/test/folders/int.spec.ts +++ b/test/folders/int.spec.ts @@ -3,18 +3,15 @@ import type { Payload } from 'payload' import path from 'path' import { fileURLToPath } from 'url' -import type { NextRESTClient } from '../helpers/NextRESTClient.js' - import { initPayloadInt } from '../helpers/initPayloadInt.js' let payload: Payload -let restClient: NextRESTClient const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) describe('folders', () => { beforeAll(async () => { - ;({ payload, restClient } = await initPayloadInt(dirname)) + ;({ payload } = await initPayloadInt(dirname)) }) afterAll(async () => { @@ -23,7 +20,7 @@ describe('folders', () => { beforeEach(async () => { await payload.delete({ - collection: 'posts', + collection: 'payload-folders', depth: 0, where: { id: { @@ -48,6 +45,7 @@ describe('folders', () => { collection: 'payload-folders', data: { name: 'Parent Folder', + folderType: ['posts'], }, }) const folderIDFromParams = parentFolder.id @@ -57,6 +55,7 @@ describe('folders', () => { data: { name: 'Nested 1', folder: folderIDFromParams, + folderType: ['posts'], }, }) @@ -65,6 +64,7 @@ describe('folders', () => { data: { name: 'Nested 2', folder: folderIDFromParams, + folderType: ['posts'], }, }) @@ -73,7 +73,7 @@ describe('folders', () => { id: folderIDFromParams, }) - expect(parentFolderQuery.documentsAndFolders.docs).toHaveLength(2) + expect(parentFolderQuery.documentsAndFolders?.docs).toHaveLength(2) }) }) @@ -82,6 +82,7 @@ describe('folders', () => { const parentFolder = await payload.create({ collection: 'payload-folders', data: { + folderType: ['posts'], name: 'Parent Folder', }, }) @@ -108,7 +109,7 @@ describe('folders', () => { id: folderIDFromParams, }) - expect(parentFolderQuery.documentsAndFolders.docs).toHaveLength(2) + expect(parentFolderQuery.documentsAndFolders?.docs).toHaveLength(2) }) }) @@ -117,6 +118,7 @@ describe('folders', () => { const parentFolder = await payload.create({ collection: 'payload-folders', data: { + folderType: ['posts'], name: 'Parent Folder', }, }) @@ -124,6 +126,7 @@ describe('folders', () => { const childFolder = await payload.create({ collection: 'payload-folders', data: { + folderType: ['posts'], name: 'Child Folder', folder: parentFolder, }, @@ -153,6 +156,7 @@ describe('folders', () => { const parentFolder = await payload.create({ collection: 'payload-folders', data: { + folderType: ['posts'], name: 'Parent Folder', }, }) @@ -168,6 +172,7 @@ describe('folders', () => { const parentFolder = await payload.create({ collection: 'payload-folders', data: { + folderType: ['posts'], name: 'Parent Folder', }, }) @@ -176,6 +181,7 @@ describe('folders', () => { data: { name: 'Child Folder', folder: parentFolder, + folderType: ['posts'], }, }) @@ -189,5 +195,154 @@ describe('folders', () => { }), ).resolves.toBeNull() }) + + describe('ensureSafeCollectionsChange', () => { + it('should prevent narrowing scope of a folder if it contains documents of a removed type', async () => { + const sharedFolder = await payload.create({ + collection: 'payload-folders', + data: { + name: 'Posts and Drafts Folder', + folderType: ['posts', 'drafts'], + }, + }) + + await payload.create({ + collection: 'posts', + data: { + title: 'Post 1', + folder: sharedFolder.id, + }, + }) + + await payload.create({ + collection: 'drafts', + data: { + title: 'Post 1', + folder: sharedFolder.id, + }, + }) + + try { + const updatedFolder = await payload.update({ + collection: 'payload-folders', + id: sharedFolder.id, + data: { + folderType: ['posts'], + }, + }) + + expect(updatedFolder).not.toBeDefined() + } catch (e: any) { + expect(e.message).toBe( + 'The folder "Posts and Drafts Folder" contains documents that still belong to the following collections: Drafts', + ) + } + }) + + it('should prevent adding scope to a folder if it contains documents outside of the new scope', async () => { + const folderAcceptsAnything = await payload.create({ + collection: 'payload-folders', + data: { + name: 'Anything Goes', + folderType: [], + }, + }) + + await payload.create({ + collection: 'posts', + data: { + title: 'Post 1', + folder: folderAcceptsAnything.id, + }, + }) + + try { + const scopedFolder = await payload.update({ + collection: 'payload-folders', + id: folderAcceptsAnything.id, + data: { + folderType: ['posts'], + }, + }) + + expect(scopedFolder).not.toBeDefined() + } catch (e: any) { + expect(e.message).toBe( + 'The folder "Anything Goes" contains documents that still belong to the following collections: Posts', + ) + } + }) + + it('should prevent narrowing scope of a folder if subfolders are assigned to any of the removed types', async () => { + const parentFolder = await payload.create({ + collection: 'payload-folders', + data: { + name: 'Parent Folder', + folderType: ['posts', 'drafts'], + }, + }) + + await payload.create({ + collection: 'payload-folders', + data: { + name: 'Parent Folder', + folderType: ['posts', 'drafts'], + folder: parentFolder.id, + }, + }) + + try { + const updatedParent = await payload.update({ + collection: 'payload-folders', + id: parentFolder.id, + data: { + folderType: ['posts'], + }, + }) + + expect(updatedParent).not.toBeDefined() + } catch (e: any) { + expect(e.message).toBe( + 'The folder "Parent Folder" contains folders that still belong to the following collections: Drafts', + ) + } + }) + + it('should prevent widening scope on a scoped subfolder', async () => { + const unscopedFolder = await payload.create({ + collection: 'payload-folders', + data: { + name: 'Parent Folder', + folderType: [], + }, + }) + + const level1Folder = await payload.create({ + collection: 'payload-folders', + data: { + name: 'Level 1 Folder', + folderType: ['posts', 'drafts'], + folder: unscopedFolder.id, + }, + }) + + try { + const level2UnscopedFolder = await payload.create({ + collection: 'payload-folders', + data: { + name: 'Level 2 Folder', + folder: level1Folder.id, + folderType: [], + }, + }) + + expect(level2UnscopedFolder).not.toBeDefined() + } catch (e: any) { + expect(e.message).toBe( + 'The folder "Level 2 Folder" must have folder-type set since its parent folder "Level 1 Folder" has a folder-type set.', + ) + } + }) + }) }) }) diff --git a/test/folders/payload-types.ts b/test/folders/payload-types.ts index 276727a036..8f1e60b6b5 100644 --- a/test/folders/payload-types.ts +++ b/test/folders/payload-types.ts @@ -201,6 +201,7 @@ export interface FolderInterface { hasNextPage?: boolean; totalDocs?: number; }; + folderType?: ('posts' | 'media' | 'drafts' | 'autosave' | 'omitted-from-browse-by')[] | null; folderSlug?: string | null; updatedAt: string; createdAt: string; @@ -419,6 +420,7 @@ export interface PayloadFoldersSelect { name?: T; folder?: T; documentsAndFolders?: T; + folderType?: T; folderSlug?: T; updatedAt?: T; createdAt?: T; diff --git a/test/folders/tsconfig.json b/test/folders/tsconfig.json new file mode 100644 index 0000000000..3c43903cfd --- /dev/null +++ b/test/folders/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} diff --git a/test/helpers/folders/applyBrowseByFolderTypeFilter.ts b/test/helpers/folders/applyBrowseByFolderTypeFilter.ts new file mode 100644 index 0000000000..1cdbefe63c --- /dev/null +++ b/test/helpers/folders/applyBrowseByFolderTypeFilter.ts @@ -0,0 +1,41 @@ +import type { Page } from '@playwright/test' + +export const applyBrowseByFolderTypeFilter = async ({ + page, + type, + on, +}: { + on: boolean + page: Page + type: { + label: string + value: string + } +}) => { + // Check if the popup is already active + let typePill = page.locator('.search-bar__actions .checkbox-popup.popup--active', { + hasText: 'Type', + }) + const isActive = (await typePill.count()) > 0 + + if (!isActive) { + typePill = page.locator('.search-bar__actions .checkbox-popup', { hasText: 'Type' }) + await typePill.locator('.popup-button', { hasText: 'Type' }).click() + } + + await typePill.locator('.field-label', { hasText: type.label }).click() + + await page.waitForURL((urlStr) => { + try { + const url = new URL(urlStr) + const relationTo = url.searchParams.get('relationTo') + if (on) { + return Boolean(relationTo?.includes(`"${type.value}"`)) + } else { + return Boolean(!relationTo?.includes(`"${type.value}"`)) + } + } catch { + return false + } + }) +} diff --git a/test/helpers/folders/clickFolderCard.ts b/test/helpers/folders/clickFolderCard.ts index b563122771..f145828420 100644 --- a/test/helpers/folders/clickFolderCard.ts +++ b/test/helpers/folders/clickFolderCard.ts @@ -1,27 +1,37 @@ -import type { Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' type Args = { doubleClick?: boolean folderName: string page: Page + rootLocator?: Locator } export async function clickFolderCard({ page, folderName, doubleClick = false, + rootLocator, }: Args): Promise { - const folderCard = page - .locator('.folder-file-card') + const folderCard = (rootLocator || page) + .locator('div[role="button"].draggable-with-click') .filter({ has: page.locator('.folder-file-card__name', { hasText: folderName }), }) .first() - const dragHandleButton = folderCard.locator('div[role="button"].folder-file-card__drag-handle') + await folderCard.waitFor({ state: 'visible' }) if (doubleClick) { - await dragHandleButton.dblclick() + // Release any modifier keys that might be held down from previous tests + await page.keyboard.up('Shift') + await page.keyboard.up('Control') + await page.keyboard.up('Alt') + await page.keyboard.up('Meta') + await folderCard.dblclick() + await expect(folderCard).toBeHidden() } else { - await dragHandleButton.click() + await folderCard.click() } } diff --git a/test/helpers/folders/createFolder.ts b/test/helpers/folders/createFolder.ts index 5ac1d06e16..f3c4785a12 100644 --- a/test/helpers/folders/createFolder.ts +++ b/test/helpers/folders/createFolder.ts @@ -1,7 +1,10 @@ import { expect, type Page } from '@playwright/test' +import { createFolderDoc } from './createFolderDoc.js' + type Args = { folderName: string + folderType?: string[] fromDropdown?: boolean page: Page } @@ -9,13 +12,15 @@ export async function createFolder({ folderName, fromDropdown = false, page, + folderType = ['Posts'], }: Args): Promise { if (fromDropdown) { - const folderDropdown = page.locator('.create-new-doc-in-folder__popup-button', { + const titleActionsLocator = page.locator('.list-header__title-actions') + const folderDropdown = titleActionsLocator.locator('.create-new-doc-in-folder__action-popup', { hasText: 'Create', }) await folderDropdown.click() - const createFolderButton = page.locator('.popup-button-list__button', { + const createFolderButton = titleActionsLocator.locator('.popup-button-list__button', { hasText: 'Folder', }) await createFolderButton.click() @@ -26,16 +31,11 @@ export async function createFolder({ await createFolderButton.click() } - const folderNameInput = page.locator( - 'dialog#create-document--header-pill-new-folder-drawer div.drawer-content-container input#field-name', - ) - - await folderNameInput.fill(folderName) - - const createButton = page.getByRole('button', { name: 'Apply Changes' }) - await createButton.click() - - await expect(page.locator('.payload-toast-container')).toContainText('successfully') + await createFolderDoc({ + page, + folderName, + folderType, + }) const folderCard = page.locator('.folder-file-card__name', { hasText: folderName }).first() await expect(folderCard).toBeVisible() diff --git a/test/helpers/folders/createFolderDoc.ts b/test/helpers/folders/createFolderDoc.ts new file mode 100644 index 0000000000..4266755e7d --- /dev/null +++ b/test/helpers/folders/createFolderDoc.ts @@ -0,0 +1,26 @@ +import { expect, type Page } from '@playwright/test' + +import { selectInput } from '../../helpers/e2e/selectInput.js' +export const createFolderDoc = async ({ + folderName, + page, + folderType, +}: { + folderName: string + folderType: string[] + page: Page +}) => { + const drawer = page.locator('dialog .collection-edit--payload-folders') + await drawer.locator('input#field-name').fill(folderName) + + await selectInput({ + multiSelect: true, + options: folderType, + selectLocator: drawer.locator('#field-folderType'), + }) + + const createButton = drawer.getByRole('button', { name: 'Save' }) + await createButton.click() + + await expect(page.locator('.payload-toast-container')).toContainText('successfully') +} diff --git a/test/helpers/folders/createFolderFromDoc.ts b/test/helpers/folders/createFolderFromDoc.ts index fe8fdaabd4..b9ff977ba6 100644 --- a/test/helpers/folders/createFolderFromDoc.ts +++ b/test/helpers/folders/createFolderFromDoc.ts @@ -1,26 +1,29 @@ import { expect, type Page } from '@playwright/test' +import { createFolder } from './createFolder.js' +import { createFolderDoc } from './createFolderDoc.js' + type Args = { folderName: string + folderType?: string[] page: Page } -export async function createFolderFromDoc({ folderName, page }: Args): Promise { +export async function createFolderFromDoc({ + folderName, + page, + folderType = ['Posts'], +}: Args): Promise { const addFolderButton = page.locator('.create-new-doc-in-folder__button', { hasText: 'Create folder', }) await addFolderButton.click() - const folderNameInput = page.locator('div.drawer-content-container input#field-name') - - await folderNameInput.fill(folderName) - - const createButton = page - .locator('button[aria-label="Apply Changes"]') - .filter({ hasText: 'Create' }) - await createButton.click() - - await expect(page.locator('.payload-toast-container')).toContainText('successfully') + await createFolderDoc({ + page, + folderName, + folderType, + }) const folderCard = page.locator('.folder-file-card__name', { hasText: folderName }).first() await expect(folderCard).toBeVisible()