feat: optionally exclude collection documents from appearing in browse-by-folder (#12654)

Adds configurations for browse-by-folder document results. This PR
**does NOT** allow for filtering out folders on a per collection basis.
That will be addressed in a future PR 👍

### Disable browse-by-folder all together
```ts
type RootFoldersConfiguration = {
  /**
   * If true, the browse by folder view will be enabled
   *
   * @default true
   */
  browseByFolder?: boolean
  // ...rest of type
}
```

### Remove document types from appearing in the browse by folder view
```ts
type CollectionFoldersConfiguration =
  | boolean
  | {
      /**
       * If true, the collection documents will be included in the browse by folder view
       *
       * @default true
       */
      browseByFolder?: boolean
    }
```

### Misc
Fixes https://github.com/payloadcms/payload/issues/12631 where adding
folders.collectionOverrides was being set on the client config - it
should be omitted.

Fixes an issue where `baseListFilters` were not being respected.
This commit is contained in:
Jarrod Flesch
2025-06-04 13:22:26 -04:00
committed by GitHub
parent 48218bccb5
commit 337f6188da
59 changed files with 1549 additions and 367 deletions

View File

@@ -23,6 +23,12 @@ On the payload config, you can configure the following settings under the `folde
// Type definition
type RootFoldersConfiguration = {
/**
* If true, the browse by folder view will be enabled
*
* @default true
*/
browseByFolder?: boolean
/**
* An array of functions to be ran when the folder collection is initialized
* This allows plugins to modify the collection configuration
@@ -82,7 +88,16 @@ To enable folders on a collection, you need to set the `admin.folders` property
```ts
// Type definition
type CollectionFoldersConfiguration = boolean
type CollectionFoldersConfiguration =
| boolean
| {
/**
* If true, the collection will be included in the browse by folder view
*
* @default true
*/
browseByFolder?: boolean
}
```
```ts

View File

@@ -23,20 +23,11 @@ export const DefaultNavClient: React.FC<{
admin: {
routes: { browseByFolder: foldersRoute },
},
collections,
folders,
routes: { admin: adminRoute },
},
} = useConfig()
const [folderCollectionSlugs] = React.useState<string[]>(() => {
return collections.reduce<string[]>((acc, collection) => {
if (collection.folders) {
acc.push(collection.slug)
}
return acc
}, [])
})
const { i18n } = useTranslation()
const folderURL = formatAdminURL({
@@ -48,7 +39,7 @@ export const DefaultNavClient: React.FC<{
return (
<Fragment>
{folderCollectionSlugs.length > 0 && <BrowseByFolderButton active={viewingRootFolderView} />}
{folders && folders.browseByFolder && <BrowseByFolderButton active={viewingRootFolderView} />}
{groups.map(({ entities, label }, key) => {
return (
<NavGroup isOpen={navPreferences?.groups?.[label]?.open} key={key} label={label}>

View File

@@ -3,6 +3,7 @@ import type {
BuildCollectionFolderViewResult,
FolderListViewServerPropsOnly,
ListQuery,
Where,
} from 'payload'
import { DefaultBrowseByFolderView, FolderProvider, HydrateAuthProvider } from '@payloadcms/ui'
@@ -10,6 +11,7 @@ import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerCompo
import { formatAdminURL } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import { getFolderData } from 'payload'
import { buildFolderWhereConstraints } from 'payload/shared'
import React from 'react'
import { getPreferences } from '../../utilities/getPreferences.js'
@@ -29,10 +31,10 @@ export const buildBrowseByFolderView = async (
args: BuildFolderViewArgs,
): Promise<BuildCollectionFolderViewResult> => {
const {
browseByFolderSlugs: browseByFolderSlugsFromArgs = [],
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
folderCollectionSlugs,
folderID,
initPageResult,
isInDrawer,
@@ -54,30 +56,83 @@ export const buildBrowseByFolderView = async (
visibleEntities,
} = initPageResult
const collections = folderCollectionSlugs.filter(
const browseByFolderSlugs = browseByFolderSlugsFromArgs.filter(
(collectionSlug) =>
permissions?.collections?.[collectionSlug]?.read &&
visibleEntities.collections.includes(collectionSlug),
)
if (!collections.length) {
if (config.folders === false || config.folders.browseByFolder === false) {
throw new Error('not-found')
}
const query = queryFromArgs || queryFromReq
const selectedCollectionSlugs: string[] =
Array.isArray(query?.relationTo) && query.relationTo.length
? query.relationTo
: [...folderCollectionSlugs, config.folders.slug]
? query.relationTo.filter(
(slug) =>
browseByFolderSlugs.includes(slug) || (config.folders && slug === config.folders.slug),
)
: [...browseByFolderSlugs, config.folders.slug]
const {
routes: { admin: adminRoute },
} = config
const folderCollectionConfig = payload.collections[config.folders.slug].config
const browseByFolderPreferences = await getPreferences<{ viewPreference: string }>(
'browse-by-folder',
payload,
user.id,
user.collection,
)
let documentWhere: undefined | Where = undefined
let folderWhere: undefined | Where = undefined
// if folderID, dont make a documentWhere since it only queries root folders
for (const collectionSlug of selectedCollectionSlugs) {
if (collectionSlug === config.folders.slug) {
const folderCollectionConstraints = await buildFolderWhereConstraints({
collectionConfig: folderCollectionConfig,
folderID,
localeCode: fullLocale?.code,
req: initPageResult.req,
search: typeof query?.search === 'string' ? query.search : undefined,
})
if (folderCollectionConstraints) {
folderWhere = folderCollectionConstraints
}
} else if (folderID) {
if (!documentWhere) {
documentWhere = {
or: [],
}
}
const collectionConfig = payload.collections[collectionSlug].config
if (collectionConfig.folders && collectionConfig.folders.browseByFolder === true) {
const collectionConstraints = await buildFolderWhereConstraints({
collectionConfig,
folderID,
localeCode: fullLocale?.code,
req: initPageResult.req,
search: typeof query?.search === 'string' ? query.search : undefined,
})
if (collectionConstraints) {
documentWhere.or.push(collectionConstraints)
}
}
}
}
const { breadcrumbs, documents, subfolders } = await getFolderData({
documentWhere,
folderID,
folderWhere,
req: initPageResult.req,
search: query?.search as string,
})
const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id
@@ -96,13 +151,6 @@ export const buildBrowseByFolderView = async (
)
}
const browseByFolderPreferences = await getPreferences<{ viewPreference: string }>(
'browse-by-folder',
payload,
user.id,
user.collection,
)
const serverProps: Omit<FolderListViewServerPropsOnly, 'collectionConfig' | 'listPreferences'> = {
documents,
i18n,
@@ -125,7 +173,7 @@ export const buildBrowseByFolderView = async (
// documents cannot be created without a parent folder in this view
const hasCreatePermissionCollectionSlugs = folderID
? [config.folders.slug, ...folderCollectionSlugs]
? [config.folders.slug, ...browseByFolderSlugs]
: [config.folders.slug]
return {
@@ -134,7 +182,8 @@ export const buildBrowseByFolderView = async (
breadcrumbs={breadcrumbs}
documents={documents}
filteredCollectionSlugs={selectedCollectionSlugs}
folderCollectionSlugs={folderCollectionSlugs}
folderCollectionSlugs={browseByFolderSlugs}
folderFieldName={config.folders.fieldName}
folderID={folderID}
subfolders={subfolders}
>

View File

@@ -8,9 +8,10 @@ import type {
import { DefaultCollectionFolderView, FolderProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import { getFolderData, parseDocumentID } from 'payload'
import { getFolderData } from 'payload'
import { buildFolderWhereConstraints } from 'payload/shared'
import React from 'react'
import { getPreferences } from '../../utilities/getPreferences.js'
@@ -37,7 +38,6 @@ export const buildCollectionFolderView = async (
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
folderCollectionSlugs,
folderID,
initPageResult,
isInDrawer,
@@ -69,12 +69,12 @@ export const buildCollectionFolderView = async (
if (collectionConfig) {
const query = queryFromArgs || queryFromReq
const collectionFolderPreferences = await getPreferences<{ viewPreference: string }>(
`${collectionSlug}-collection-folder`,
payload,
user.id,
user.collection,
)
const collectionFolderPreferences = await getPreferences<{
sort?: string
viewPreference: string
}>(`${collectionSlug}-collection-folder`, payload, user.id, user.collection)
const sortPreference = collectionFolderPreferences?.value.sort
const {
routes: { admin: adminRoute },
@@ -82,38 +82,45 @@ export const buildCollectionFolderView = async (
if (
(!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) ||
!folderCollectionSlugs.includes(collectionSlug)
!config.folders
) {
throw new Error('not-found')
}
const whereConstraints = [
mergeListSearchAndWhere({
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: (query?.where as Where) || undefined,
}),
]
let folderWhere: undefined | Where
const folderCollectionConfig = payload.collections[config.folders.slug].config
const folderCollectionConstraints = await buildFolderWhereConstraints({
collectionConfig: folderCollectionConfig,
folderID,
localeCode: fullLocale?.code,
req: initPageResult.req,
search: typeof query?.search === 'string' ? query.search : undefined,
sort: sortPreference,
})
if (folderID) {
whereConstraints.push({
[config.folders.fieldName]: {
equals: parseDocumentID({ id: folderID, collectionSlug, payload }),
},
})
} else {
whereConstraints.push({
[config.folders.fieldName]: {
exists: false,
},
})
if (folderCollectionConstraints) {
folderWhere = folderCollectionConstraints
}
let documentWhere: undefined | Where
const collectionConstraints = await buildFolderWhereConstraints({
collectionConfig,
folderID,
localeCode: fullLocale?.code,
req: initPageResult.req,
search: typeof query?.search === 'string' ? query.search : undefined,
sort: sortPreference,
})
if (collectionConstraints) {
documentWhere = collectionConstraints
}
const { breadcrumbs, documents, subfolders } = await getFolderData({
collectionSlug,
documentWhere,
folderID,
folderWhere,
req: initPageResult.req,
search: query?.search as string,
})
const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id
@@ -175,7 +182,8 @@ export const buildCollectionFolderView = async (
breadcrumbs={breadcrumbs}
collectionSlug={collectionSlug}
documents={documents}
folderCollectionSlugs={folderCollectionSlugs}
folderCollectionSlugs={[collectionSlug]}
folderFieldName={config.folders.fieldName}
folderID={folderID}
search={search}
subfolders={subfolders}

View File

@@ -73,9 +73,9 @@ type GetRouteDataArgs = {
}
type GetRouteDataResult = {
browseByFolderSlugs: CollectionSlug[]
DefaultView: ViewFromConfig
documentSubViewType?: DocumentSubViewTypes
folderCollectionSlugs: CollectionSlug[]
folderID?: string
initPageOptions: Parameters<typeof initPage>[0]
serverProps: ServerPropsFromView
@@ -113,12 +113,16 @@ export const getRouteData = ({
let matchedCollection: SanitizedConfig['collections'][number] = undefined
let matchedGlobal: SanitizedConfig['globals'][number] = undefined
const folderCollectionSlugs = config.collections.reduce((acc, { slug, folders }) => {
if (folders) {
return [...acc, slug]
}
return acc
}, [])
const isBrowseByFolderEnabled = config.folders && config.folders.browseByFolder
const browseByFolderSlugs =
(isBrowseByFolderEnabled &&
config.collections.reduce((acc, { slug, folders }) => {
if (folders && folders.browseByFolder) {
return [...acc, slug]
}
return acc
}, [])) ||
[]
const serverProps: ServerPropsFromView = {
viewActions: config?.admin?.components?.actions || [],
@@ -187,7 +191,7 @@ export const getRouteData = ({
viewType = 'account'
}
if (folderCollectionSlugs.length && viewKey === 'browseByFolder') {
if (isBrowseByFolderEnabled && viewKey === 'browseByFolder') {
templateType = 'default'
viewType = 'folders'
}
@@ -204,7 +208,7 @@ export const getRouteData = ({
templateType = 'minimal'
viewType = 'reset'
} else if (
folderCollectionSlugs.length &&
isBrowseByFolderEnabled &&
`/${segmentOne}` === config.admin.routes.browseByFolder
) {
// --> /browse-by-folder/:folderID
@@ -260,10 +264,7 @@ export const getRouteData = ({
templateType = 'minimal'
viewType = 'verify'
} else if (isCollection && matchedCollection) {
if (
segmentThree === config.folders.slug &&
folderCollectionSlugs.includes(matchedCollection.slug)
) {
if (config.folders && segmentThree === config.folders.slug && matchedCollection.folders) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:folderID
@@ -333,9 +334,9 @@ export const getRouteData = ({
serverProps.viewActions.reverse()
return {
browseByFolderSlugs,
DefaultView: ViewToRender,
documentSubViewType,
folderCollectionSlugs,
folderID,
initPageOptions,
serverProps,

View File

@@ -63,9 +63,9 @@ export const RootPage = async ({
const searchParams = await searchParamsPromise
const {
browseByFolderSlugs,
DefaultView,
documentSubViewType,
folderCollectionSlugs,
folderID: folderIDParam,
initPageOptions,
serverProps,
@@ -140,17 +140,19 @@ export const RootPage = async ({
})
const payload = initPageResult?.req.payload
const folderID = parseDocumentID({
id: folderIDParam,
collectionSlug: payload.config.folders.slug,
payload,
})
const folderID = payload.config.folders
? parseDocumentID({
id: folderIDParam,
collectionSlug: payload.config.folders.slug,
payload,
})
: undefined
const RenderedView = RenderServerComponent({
clientProps: {
browseByFolderSlugs,
clientConfig,
documentSubViewType,
folderCollectionSlugs,
viewType,
} satisfies AdminViewClientProps,
Component: DefaultView.payloadComponent,

View File

@@ -129,7 +129,7 @@ export const generatePageMetadata = async ({
// --> /:collectionSlug/verify/:token
meta = await generateVerifyViewMetadata({ config, i18n })
} else if (isCollection) {
if (segmentThree === config.folders.slug) {
if (config.folders && segmentThree === config.folders.slug) {
if (folderCollectionSlugs.includes(collectionConfig.slug)) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug

View File

@@ -29,9 +29,9 @@ export type AdminViewConfig = {
}
export type AdminViewClientProps = {
browseByFolderSlugs?: SanitizedCollectionConfig['slug'][]
clientConfig: ClientConfig
documentSubViewType?: DocumentSubViewTypes
folderCollectionSlugs?: SanitizedCollectionConfig['slug'][]
viewType: ViewTypes
}

View File

@@ -176,6 +176,14 @@ export const sanitizeCollection = async (
}
}
if (sanitized.folders === true) {
sanitized.folders = {
browseByFolder: true,
}
} else if (sanitized.folders) {
sanitized.folders.browseByFolder = sanitized.folders.browseByFolder ?? true
}
if (sanitized.upload) {
if (sanitized.upload === true) {
sanitized.upload = {}

View File

@@ -452,7 +452,7 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
/**
* Enables folders for this collection
*/
folders?: CollectionFoldersConfiguration
folders?: boolean | CollectionFoldersConfiguration
/**
* Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks
*/
@@ -602,7 +602,7 @@ export type SanitizedJoins = {
export interface SanitizedCollectionConfig
extends Omit<
DeepRequired<CollectionConfig>,
'admin' | 'auth' | 'endpoints' | 'fields' | 'slug' | 'upload' | 'versions'
'admin' | 'auth' | 'endpoints' | 'fields' | 'folders' | 'slug' | 'upload' | 'versions'
> {
admin: CollectionAdminOptions
auth: Auth
@@ -616,6 +616,7 @@ export interface SanitizedCollectionConfig
/**
* Object of collections to join 'Join Fields object keyed by collection
*/
folders: CollectionFoldersConfiguration | false
joins: SanitizedJoins
/**

View File

@@ -144,6 +144,17 @@ export const createClientConfig = ({
importMap,
})
break
case 'folders':
if (config.folders) {
clientConfig.folders = {
slug: config.folders.slug,
browseByFolder: config.folders.browseByFolder,
debug: config.folders.debug,
fieldName: config.folders.fieldName,
}
}
break
case 'globals':
;(clientConfig.globals as ClientGlobalConfig[]) = createClientGlobalConfigs({
defaultIDType: config.db.defaultIDType,
@@ -152,7 +163,6 @@ export const createClientConfig = ({
importMap,
})
break
case 'localization':
if (typeof config.localization === 'object' && config.localization) {
clientConfig.localization = {}

View File

@@ -112,13 +112,6 @@ export const addDefaultsToConfig = (config: Config): Config => {
},
}
config.folders = {
slug: foldersSlug,
debug: false,
fieldName: parentFolderFieldName,
...(config.folders || {}),
}
config.bin = config.bin ?? []
config.collections = config.collections ?? []
config.cookiePrefix = config.cookiePrefix ?? 'payload'
@@ -169,5 +162,18 @@ export const addDefaultsToConfig = (config: Config): Config => {
...(config.auth || {}),
}
const hasFolderCollections = config.collections.some((collection) => Boolean(collection.folders))
if (hasFolderCollections) {
config.folders = {
slug: foldersSlug,
browseByFolder: true,
debug: false,
fieldName: parentFolderFieldName,
...(config.folders || {}),
}
} else {
config.folders = false
}
return config
}

View File

@@ -991,7 +991,7 @@ export type Config = {
* Options for folder view within the admin panel
* @experimental this feature may change in minor versions until it is fully stable
*/
folders?: RootFoldersConfiguration
folders?: false | RootFoldersConfiguration
/**
* @see https://payloadcms.com/docs/configuration/globals#global-configs
*/

View File

@@ -50,6 +50,7 @@ export type {
Subfolder,
} from '../folders/types.js'
export { buildFolderWhereConstraints } from '../folders/utils/buildFolderWhereConstraints.js'
export { formatFolderOrDocumentItem } from '../folders/utils/formatFolderOrDocumentItem.js'
export { validOperators, validOperatorSet } from '../types/constants.js'

View File

@@ -4,7 +4,7 @@ import type { CollectionSlug } from '../index.js'
import { createFolderCollection } from './createFolderCollection.js'
export async function addFolderCollections(config: NonNullable<Config>): Promise<void> {
if (!config.collections) {
if (!config.collections || !config.folders) {
return
}

View File

@@ -1,7 +1,8 @@
import httpStatus from 'http-status'
import type { Endpoint } from '../../index.js'
import type { Endpoint, Where } from '../../index.js'
import { buildFolderWhereConstraints } from '../utils/buildFolderWhereConstraints.js'
import { getFolderData } from '../utils/getFolderData.js'
export const populateFolderDataEndpoint: Endpoint = {
@@ -17,9 +18,12 @@ export const populateFolderDataEndpoint: Endpoint = {
)
}
const folderCollection = Boolean(req.payload.collections?.[req.payload.config.folders.slug])
if (!folderCollection) {
if (
!(
req.payload.config.folders &&
Boolean(req.payload.collections?.[req.payload.config.folders.slug])
)
) {
return Response.json(
{
message: 'Folders are not configured',
@@ -30,13 +34,100 @@ export const populateFolderDataEndpoint: Endpoint = {
)
}
const data = await getFolderData({
collectionSlug: req.searchParams?.get('collectionSlug') || undefined,
// 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',

View File

@@ -3,6 +3,7 @@ import type { CollectionAfterChangeHook, Payload } from '../../index.js'
import { extractID } from '../../utilities/extractID.js'
type Args = {
folderCollectionSlug: string
folderFieldName: string
folderID: number | string
parentIDToFind: number | string
@@ -14,6 +15,7 @@ type Args = {
* recursively checking upwards through the folder hierarchy.
*/
async function isChildOfFolder({
folderCollectionSlug,
folderFieldName,
folderID,
parentIDToFind,
@@ -21,7 +23,7 @@ async function isChildOfFolder({
}: Args): Promise<boolean> {
const parentFolder = await payload.findByID({
id: folderID,
collection: payload.config.folders.slug,
collection: folderCollectionSlug,
})
const parentFolderID = parentFolder[folderFieldName]
@@ -39,6 +41,7 @@ async function isChildOfFolder({
}
return isChildOfFolder({
folderCollectionSlug,
folderFieldName,
folderID: parentFolderID,
parentIDToFind,
@@ -71,10 +74,15 @@ export const reparentChildFolder = ({
folderFieldName: string
}): CollectionAfterChangeHook => {
return async ({ doc, previousDoc, req }) => {
if (previousDoc[folderFieldName] !== doc[folderFieldName] && doc[folderFieldName]) {
if (
previousDoc[folderFieldName] !== doc[folderFieldName] &&
doc[folderFieldName] &&
req.payload.config.folders
) {
const newParentFolderID = extractID(doc[folderFieldName])
const isMovingToChild = newParentFolderID
? await isChildOfFolder({
folderCollectionSlug: req.payload.config.folders.slug,
folderFieldName,
folderID: newParentFolderID,
parentIDToFind: doc.id,

View File

@@ -70,6 +70,12 @@ export type GetFolderDataResult = {
}
export type RootFoldersConfiguration = {
/**
* If true, the browse by folder view will be enabled
*
* @default true
*/
browseByFolder?: boolean
/**
* An array of functions to be ran when the folder collection is initialized
* This allows plugins to modify the collection configuration
@@ -99,4 +105,11 @@ export type RootFoldersConfiguration = {
slug?: string
}
export type CollectionFoldersConfiguration = boolean
export type CollectionFoldersConfiguration = {
/**
* If true, the collection will be included in the browse by folder view
*
* @default true
*/
browseByFolder?: boolean
}

View File

@@ -0,0 +1,65 @@
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { PayloadRequest, Where } from '../../types/index.js'
import { combineWhereConstraints } from '../../utilities/combineWhereConstraints.js'
import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js'
type Args = {
collectionConfig: SanitizedCollectionConfig
folderID?: number | string
localeCode?: string
req: PayloadRequest
search?: string
sort?: string
}
export async function buildFolderWhereConstraints({
collectionConfig,
folderID,
localeCode,
req,
search = '',
sort,
}: Args): Promise<undefined | Where> {
const constraints: Where[] = [
mergeListSearchAndWhere({
collectionConfig,
search,
// where // cannot have where since fields in folders and collection will differ
}),
]
if (typeof collectionConfig.admin?.baseListFilter === 'function') {
const baseListFilterConstraint = await collectionConfig.admin.baseListFilter({
limit: 0,
locale: localeCode,
page: 1,
req,
sort:
sort ||
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : 'id'),
})
if (baseListFilterConstraint) {
constraints.push(baseListFilterConstraint)
}
}
if (folderID) {
// build folder join where constraints
constraints.push({
relationTo: {
equals: collectionConfig.slug,
},
})
}
const filteredConstraints = constraints.filter(Boolean)
if (filteredConstraints.length > 1) {
return combineWhereConstraints(filteredConstraints)
} else if (filteredConstraints.length === 1) {
return filteredConstraints[0]
}
return undefined
}

View File

@@ -16,8 +16,8 @@ export const getFolderBreadcrumbs = async ({
req,
}: GetFolderBreadcrumbsArgs): Promise<FolderBreadcrumb[] | null> => {
const { payload, user } = req
const folderFieldName: string = payload.config.folders.fieldName
if (folderID) {
if (folderID && payload.config.folders) {
const folderFieldName: string = payload.config.folders.fieldName
const folderQuery = await payload.find({
collection: payload.config.folders.slug,
depth: 0,

View File

@@ -1,5 +1,5 @@
import type { CollectionSlug } from '../../index.js'
import type { PayloadRequest } from '../../types/index.js'
import type { PayloadRequest, Where } from '../../types/index.js'
import type { GetFolderDataResult } from '../types.js'
import { parseDocumentID } from '../../index.js'
@@ -14,27 +14,38 @@ type Args = {
* @example 'posts'
*/
collectionSlug?: CollectionSlug
/**
* Optional where clause to filter documents by
* @default undefined
*/
documentWhere?: Where
/**
* The ID of the folder to query documents from
* @default undefined
*/
folderID?: number | string
req: PayloadRequest
/**
* Search term to filter documents by - only applicable IF `collectionSlug` exists and NO `folderID` is provided
/** Optional where clause to filter subfolders by
* @default undefined
*/
search?: string
folderWhere?: Where
req: PayloadRequest
}
/**
* Query for documents, subfolders and breadcrumbs for a given folder
*/
export const getFolderData = async ({
collectionSlug,
documentWhere,
folderID: _folderID,
folderWhere,
req,
search,
}: Args): Promise<GetFolderDataResult> => {
const { payload } = req
if (payload.config.folders === false) {
throw new Error('Folders are not enabled')
}
const parentFolderID = parseDocumentID({
id: _folderID,
collectionSlug: payload.config.folders.slug,
@@ -49,7 +60,8 @@ export const getFolderData = async ({
if (parentFolderID) {
// subfolders and documents are queried together
const documentAndSubfolderPromise = queryDocumentsAndFoldersFromJoin({
collectionSlug,
documentWhere,
folderWhere,
parentFolderID,
req,
})
@@ -67,14 +79,16 @@ export const getFolderData = async ({
// subfolders and documents are queried separately
const subfoldersPromise = getOrphanedDocs({
collectionSlug: payload.config.folders.slug,
folderFieldName: payload.config.folders.fieldName,
req,
search,
where: folderWhere,
})
const documentsPromise = collectionSlug
? getOrphanedDocs({
collectionSlug,
folderFieldName: payload.config.folders.fieldName,
req,
search,
where: documentWhere,
})
: Promise.resolve([])
const [breadcrumbs, subfolders, documents] = await Promise.all([

View File

@@ -1,8 +1,9 @@
import type { PaginatedDocs } from '../../database/types.js'
import type { CollectionSlug } from '../../index.js'
import type { Document, PayloadRequest } from '../../types/index.js'
import type { Document, PayloadRequest, Where } from '../../types/index.js'
import type { FolderOrDocument } from '../types.js'
import { APIError } from '../../errors/APIError.js'
import { combineWhereConstraints } from '../../utilities/combineWhereConstraints.js'
import { formatFolderOrDocumentItem } from './formatFolderOrDocumentItem.js'
type QueryDocumentsAndFoldersResults = {
@@ -10,40 +11,37 @@ type QueryDocumentsAndFoldersResults = {
subfolders: FolderOrDocument[]
}
type QueryDocumentsAndFoldersArgs = {
collectionSlug?: CollectionSlug
/**
* Optional where clause to filter documents by
* @default undefined
*/
documentWhere?: Where
/** Optional where clause to filter subfolders by
* @default undefined
*/
folderWhere?: Where
parentFolderID: number | string
req: PayloadRequest
}
export async function queryDocumentsAndFoldersFromJoin({
collectionSlug,
documentWhere,
folderWhere,
parentFolderID,
req,
}: QueryDocumentsAndFoldersArgs): Promise<QueryDocumentsAndFoldersResults> {
const { payload, user } = req
const folderCollectionSlugs: string[] = payload.config.collections.reduce<string[]>(
(acc, collection) => {
if (collection?.folders) {
acc.push(collection.slug)
}
return acc
},
[],
)
if (payload.config.folders === false) {
throw new APIError('Folders are not enabled', 500)
}
const subfolderDoc = (await payload.find({
collection: payload.config.folders.slug,
joins: {
documentsAndFolders: {
limit: 100_000,
limit: 100_000_000,
sort: 'name',
where: {
relationTo: {
in: [
payload.config.folders.slug,
...(collectionSlug ? [collectionSlug] : folderCollectionSlugs),
],
},
},
where: combineWhereConstraints([folderWhere, documentWhere], 'or'),
},
},
limit: 1,
@@ -61,6 +59,9 @@ export async function queryDocumentsAndFoldersFromJoin({
const results: QueryDocumentsAndFoldersResults = childrenDocs.reduce(
(acc: QueryDocumentsAndFoldersResults, doc: Document) => {
if (!payload.config.folders) {
return acc
}
const { relationTo, value } = doc
const item = formatFolderOrDocumentItem({
folderFieldName: payload.config.folders.fieldName,

View File

@@ -1,42 +1,41 @@
import type { CollectionSlug, PayloadRequest, Where } from '../../index.js'
import type { FolderOrDocument } from '../types.js'
import { combineWhereConstraints } from '../../utilities/combineWhereConstraints.js'
import { formatFolderOrDocumentItem } from './formatFolderOrDocumentItem.js'
type Args = {
collectionSlug: CollectionSlug
folderFieldName: string
req: PayloadRequest
search?: string
/**
* Optional where clause to filter documents by
* @default undefined
*/
where?: Where
}
export async function getOrphanedDocs({
collectionSlug,
folderFieldName,
req,
search,
where,
}: Args): Promise<FolderOrDocument[]> {
const { payload, user } = req
let whereConstraints: Where = {
const noParentFolderConstraint: Where = {
or: [
{
[payload.config.folders.fieldName]: {
[folderFieldName]: {
exists: false,
},
},
{
[payload.config.folders.fieldName]: {
[folderFieldName]: {
equals: null,
},
},
],
}
if (collectionSlug && search && payload.collections[collectionSlug]?.config.admin?.useAsTitle) {
whereConstraints = {
[payload.collections[collectionSlug].config.admin.useAsTitle]: {
like: search,
},
}
}
const orphanedFolders = await payload.find({
collection: collectionSlug,
limit: 0,
@@ -44,13 +43,15 @@ export async function getOrphanedDocs({
req,
sort: payload.collections[collectionSlug]?.config.admin.useAsTitle,
user,
where: whereConstraints,
where: where
? combineWhereConstraints([noParentFolderConstraint, where])
: noParentFolderConstraint,
})
return (
orphanedFolders?.docs.map((doc) =>
formatFolderOrDocumentItem({
folderFieldName: payload.config.folders.fieldName,
folderFieldName,
isUpload: Boolean(payload.collections[collectionSlug]?.config.upload),
relationTo: collectionSlug,
useAsTitle: payload.collections[collectionSlug]?.config.admin.useAsTitle,

View File

@@ -169,12 +169,13 @@ export function EditForm({
<Upload_v4
collectionSlug={collectionConfig.slug}
customActions={[
collectionConfig.folders && (
folders && collectionConfig.folders && (
<MoveDocToFolder
buttonProps={{
buttonStyle: 'pill',
size: 'small',
}}
folderCollectionSlug={folders.slug}
folderFieldName={folders.fieldName}
key="move-doc-to-folder"
/>

View File

@@ -171,7 +171,12 @@ export const DocumentControls: React.FC<{
{showLockedMetaIcon && (
<Locked className={`${baseClass}__locked-controls`} user={user} />
)}
{showFolderMetaIcon && <MoveDocToFolder folderFieldName={config.folders.fieldName} />}
{showFolderMetaIcon && config.folders && (
<MoveDocToFolder
folderCollectionSlug={config.folders.slug}
folderFieldName={config.folders.fieldName}
/>
)}
</div>
) : null}

View File

@@ -9,16 +9,18 @@ import React, { useEffect } from 'react'
import { MoveDocToFolderButton, useConfig, useTranslation } from '../../../exports/client/index.js'
type Props = {
collectionSlug: string
data: Data
docTitle: string
folderFieldName: string
readonly collectionSlug: string
readonly data: Data
readonly docTitle: string
readonly folderCollectionSlug: string
readonly folderFieldName: string
}
export const FolderTableCellClient = ({
collectionSlug,
data,
docTitle,
folderCollectionSlug,
folderFieldName,
}: Props) => {
const docID = data.id
@@ -54,14 +56,14 @@ export const FolderTableCellClient = ({
console.error('Error moving document to folder', error)
}
},
[config.routes.api, collectionSlug, docID, t],
[config.routes.api, collectionSlug, docID, folderFieldName, t],
)
useEffect(() => {
const loadFolderName = async () => {
try {
const req = await fetch(
`${config.routes.api}/${config.folders.slug}${intialFolderID ? `/${intialFolderID}` : ''}`,
`${config.routes.api}/${folderCollectionSlug}${intialFolderID ? `/${intialFolderID}` : ''}`,
{
credentials: 'include',
headers: {
@@ -83,7 +85,7 @@ export const FolderTableCellClient = ({
void loadFolderName()
hasLoadedFolderName.current = true
}
}, [])
}, [config.routes.api, folderCollectionSlug, intialFolderID, t])
return (
<MoveDocToFolderButton
@@ -94,6 +96,8 @@ export const FolderTableCellClient = ({
docData={data as FolderOrDocument['value']}
docID={docID}
docTitle={docTitle}
folderCollectionSlug={folderCollectionSlug}
folderFieldName={folderFieldName}
fromFolderID={fromFolderID}
fromFolderName={fromFolderName}
modalSlug={`move-doc-to-folder-cell--${docID}`}

View File

@@ -9,11 +9,16 @@ export const FolderTableCell = (props: DefaultServerCellComponentProps) => {
(props.collectionConfig.upload ? props.rowData?.filename : props.rowData?.title) ||
props.rowData.id
if (!props.payload.config.folders) {
return null
}
return (
<FolderTableCellClient
collectionSlug={props.collectionSlug}
data={props.rowData}
docTitle={titleToRender}
folderCollectionSlug={props.payload.config.folders.slug}
folderFieldName={props.payload.config.folders.fieldName}
/>
)

View File

@@ -13,14 +13,15 @@ import './index.scss'
const baseClass = 'collection-type'
export function CollectionTypePill() {
const { filterItems, folderCollectionSlug, visibleCollectionSlugs } = useFolder()
const { filterItems, folderCollectionSlug, folderCollectionSlugs, visibleCollectionSlugs } =
useFolder()
const { i18n, t } = useTranslation()
const { config, getEntityConfig } = useConfig()
const [allCollectionOptions] = React.useState(() => {
return config.collections.reduce(
(acc, collection) => {
if (collection.folders) {
if (collection.folders && folderCollectionSlugs.includes(collection.slug)) {
acc.push({
label: getTranslation(collection.labels?.plural, i18n),
value: collection.slug,

View File

@@ -28,6 +28,7 @@ export function CurrentFolderActions({ className }: Props) {
currentFolder,
folderCollectionConfig,
folderCollectionSlug,
folderFieldName,
folderID,
moveToFolder,
renameFolder,
@@ -84,6 +85,8 @@ export function CurrentFolderActions({ className }: Props) {
<MoveItemsToFolderDrawer
action="moveItemToFolder"
drawerSlug={moveToFolderDrawerSlug}
folderCollectionSlug={folderCollectionSlug}
folderFieldName={folderFieldName}
fromFolderID={currentFolder?.value.id}
fromFolderName={currentFolder?.value._folderOrDocumentTitle}
itemsToMove={[currentFolder]}

View File

@@ -46,6 +46,8 @@ type ActionProps =
}
export type MoveToFolderDrawerProps = {
readonly drawerSlug: string
readonly folderCollectionSlug: string
readonly folderFieldName: string
readonly fromFolderID?: number | string
readonly fromFolderName?: string
readonly itemsToMove: FolderOrDocument[]
@@ -75,11 +77,7 @@ export function MoveItemsToFolderDrawer(props: MoveToFolderDrawerProps) {
function LoadFolderData(props: MoveToFolderDrawerProps) {
const {
config: {
folders: { slug: folderCollectionSlug },
routes,
serverURL,
},
config: { routes, serverURL },
} = useConfig()
const [subfolders, setSubfolders] = React.useState<FolderOrDocument[]>([])
const [documents, setDocuments] = React.useState<FolderOrDocument[]>([])
@@ -92,7 +90,7 @@ function LoadFolderData(props: MoveToFolderDrawerProps) {
try {
const folderDataReq = await fetch(
`${serverURL}${routes.api}/${folderCollectionSlug}/populate-folder-data${props.fromFolderID ? `?folderID=${props.fromFolderID}` : ''}`,
`${serverURL}${routes.api}/${props.folderCollectionSlug}/populate-folder-data${props.fromFolderID ? `?folderID=${props.fromFolderID}` : ''}`,
{
credentials: 'include',
headers: {
@@ -122,7 +120,7 @@ function LoadFolderData(props: MoveToFolderDrawerProps) {
if (!hasLoaded) {
void onLoad()
}
}, [folderCollectionSlug, routes.api, serverURL, hasLoaded, props.fromFolderID])
}, [props.folderCollectionSlug, routes.api, serverURL, hasLoaded, props.fromFolderID])
if (!hasLoaded) {
return <LoadingOverlay />
@@ -134,6 +132,7 @@ function LoadFolderData(props: MoveToFolderDrawerProps) {
breadcrumbs={breadcrumbs}
documents={documents}
folderCollectionSlugs={[]}
folderFieldName={props.folderFieldName}
folderID={props.fromFolderID}
subfolders={subfolders}
>

View File

@@ -7,9 +7,13 @@ import './index.scss'
const baseClass = 'folder-edit-field'
export const FolderEditField = (props: RelationshipFieldServerProps) => {
if (props.payload.config.folders === false) {
return null
}
return (
<MoveDocToFolder
className={baseClass}
folderCollectionSlug={props.payload.config.folders.slug}
folderFieldName={props.payload.config.folders.fieldName}
/>
)

View File

@@ -27,11 +27,13 @@ const baseClass = 'move-doc-to-folder'
export function MoveDocToFolder({
buttonProps,
className = '',
folderCollectionSlug,
folderFieldName,
}: {
buttonProps?: Partial<ButtonProps>
className?: string
folderFieldName: string
readonly buttonProps?: Partial<ButtonProps>
readonly className?: string
readonly folderCollectionSlug: string
readonly folderFieldName: string
}) {
const { t } = useTranslation()
const dispatchField = useFormFields(([_, dispatch]) => dispatch)
@@ -49,7 +51,7 @@ export function MoveDocToFolder({
React.useEffect(() => {
async function fetchFolderLabel() {
if (fromFolderID && (typeof fromFolderID === 'string' || typeof fromFolderID === 'number')) {
const response = await fetch(`${config.routes.api}/${config.folders.slug}/${fromFolderID}`)
const response = await fetch(`${config.routes.api}/${folderCollectionSlug}/${fromFolderID}`)
const folderData = await response.json()
setFromFolderName(folderData?.name || t('folder:noFolder'))
} else {
@@ -58,7 +60,7 @@ export function MoveDocToFolder({
}
void fetchFolderLabel()
}, [config.folders.slug, config.routes.api, fromFolderID, t])
}, [folderCollectionSlug, config.routes.api, fromFolderID, t])
return (
<MoveDocToFolderButton
@@ -68,6 +70,8 @@ export function MoveDocToFolder({
docData={initialData as FolderOrDocument['value']}
docID={id}
docTitle={title}
folderCollectionSlug={folderCollectionSlug}
folderFieldName={folderFieldName}
fromFolderID={fromFolderID as number | string}
fromFolderName={fromFolderName}
modalSlug={`move-to-folder-${modalID}`}
@@ -87,17 +91,19 @@ export function MoveDocToFolder({
}
type MoveDocToFolderButtonProps = {
buttonProps?: Partial<ButtonProps>
className?: string
collectionSlug: string
docData: FolderOrDocument['value']
docID: number | string
docTitle?: string
fromFolderID?: number | string
fromFolderName: string
modalSlug: string
onConfirm?: (args: { id: number | string; name: string }) => Promise<void> | void
skipConfirmModal?: boolean
readonly buttonProps?: Partial<ButtonProps>
readonly className?: string
readonly collectionSlug: string
readonly docData: FolderOrDocument['value']
readonly docID: number | string
readonly docTitle?: string
readonly folderCollectionSlug: string
readonly folderFieldName: string
readonly fromFolderID?: number | string
readonly fromFolderName: string
readonly modalSlug: string
readonly onConfirm?: (args: { id: number | string; name: string }) => Promise<void> | void
readonly skipConfirmModal?: boolean
}
/**
@@ -110,6 +116,8 @@ export const MoveDocToFolderButton = ({
docData,
docID,
docTitle,
folderCollectionSlug,
folderFieldName,
fromFolderID,
fromFolderName,
modalSlug,
@@ -143,6 +151,8 @@ export const MoveDocToFolderButton = ({
<MoveItemsToFolderDrawer
action="moveItemToFolder"
drawerSlug={drawerSlug}
folderCollectionSlug={folderCollectionSlug}
folderFieldName={folderFieldName}
fromFolderID={fromFolderID}
fromFolderName={fromFolderName}
itemsToMove={[

View File

@@ -13,14 +13,23 @@ import './index.scss'
const baseClass = 'list-folder-pills'
type ListFolderPillsProps = {
collectionConfig: ClientCollectionConfig
viewType: 'folders' | 'list'
readonly collectionConfig: ClientCollectionConfig
readonly folderCollectionSlug: string
readonly viewType: 'folders' | 'list'
}
export function ListFolderPills({ collectionConfig, viewType }: ListFolderPillsProps) {
export function ListFolderPills({
collectionConfig,
folderCollectionSlug,
viewType,
}: ListFolderPillsProps) {
const { i18n, t } = useTranslation()
const { config } = useConfig()
if (!folderCollectionSlug) {
return null
}
return (
<div className={baseClass}>
<Button
@@ -35,7 +44,7 @@ export function ListFolderPills({ collectionConfig, viewType }: ListFolderPillsP
el={viewType === 'list' ? 'link' : 'div'}
to={formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${collectionConfig.slug}/${config.folders.slug}`,
path: `/collections/${collectionConfig.slug}/${folderCollectionSlug}`,
serverURL: config.serverURL,
})}
>

View File

@@ -35,7 +35,7 @@ export function ListCreateNewDocInFolderButton({
const { i18n } = useTranslation()
const { closeModal, openModal } = useModal()
const { config } = useConfig()
const { folderCollectionConfig, folderID } = useFolder()
const { folderCollectionConfig, folderFieldName, folderID } = useFolder()
const [createCollectionSlug, setCreateCollectionSlug] = React.useState<string | undefined>()
const [enabledCollections] = React.useState<ClientCollectionConfig[]>(() =>
collectionSlugs.reduce((acc, collectionSlug) => {
@@ -114,7 +114,7 @@ export function ListCreateNewDocInFolderButton({
collectionSlug={createCollectionSlug}
drawerSlug={newDocInFolderDrawerSlug}
initialData={{
[config.folders.fieldName]: folderID,
[folderFieldName]: folderID,
}}
onSave={({ doc }) => {
closeModal(newDocInFolderDrawerSlug)

View File

@@ -37,6 +37,10 @@ export type FolderContextValue = {
focusedRowIndex: number
folderCollectionConfig: ClientCollectionConfig
folderCollectionSlug: string
/**
* Folder enabled collection slugs that can be populated within the provider
*/
readonly folderCollectionSlugs?: CollectionSlug[]
folderFieldName: string
folderID?: number | string
getSelectedItems?: () => FolderOrDocument[]
@@ -86,6 +90,7 @@ const Context = React.createContext<FolderContextValue>({
focusedRowIndex: -1,
folderCollectionConfig: null,
folderCollectionSlug: '',
folderCollectionSlugs: [],
folderFieldName: 'folder',
folderID: undefined,
getSelectedItems: () => [],
@@ -158,9 +163,13 @@ export type FolderProviderProps = {
*/
readonly filteredCollectionSlugs?: CollectionSlug[]
/**
* Folder enabled collection slugs
* Folder enabled collection slugs that can be populated within the provider
*/
readonly folderCollectionSlugs: CollectionSlug[]
/**
* The name of the field that contains the folder relation
*/
readonly folderFieldName: string
/**
* The ID of the current folder
*/
@@ -190,6 +199,7 @@ export function FolderProvider({
documents: allDocumentsFromProps = [],
filteredCollectionSlugs,
folderCollectionSlugs = [],
folderFieldName,
folderID: _folderIDFromProps = undefined,
search: _searchFromProps,
sort,
@@ -197,7 +207,6 @@ export function FolderProvider({
}: FolderProviderProps) {
const parentFolderContext = useFolder()
const { config, getEntityConfig } = useConfig()
const folderFieldName = config.folders.fieldName
const { routes, serverURL } = config
const drawerDepth = useDrawerDepth()
const { t } = useTranslation()
@@ -205,7 +214,9 @@ export function FolderProvider({
const { startRouteTransition } = useRouteTransition()
const [folderCollectionConfig] = React.useState(() =>
config.collections.find((collection) => collection.slug === config.folders.slug),
config.collections.find(
(collection) => config.folders && collection.slug === config.folders.slug,
),
)
const folderCollectionSlug = folderCollectionConfig.slug
@@ -874,7 +885,7 @@ export function FolderProvider({
setAllSubfolders(sortedAllSubfolders)
}
},
[activeFolderID, documents, separateItems, sortItems, subfolders],
[activeFolderID, documents, separateItems, sortItems, subfolders, parentFolderContext],
)
/**
@@ -964,7 +975,7 @@ export function FolderProvider({
const formattedItems: FolderOrDocument[] = docs.map<FolderOrDocument>(
(doc: Document) =>
formatFolderOrDocumentItem({
folderFieldName: config.folders.fieldName,
folderFieldName,
isUpload: Boolean(collectionConfig.upload),
relationTo: collectionSlug,
useAsTitle: collectionConfig.admin.useAsTitle,
@@ -1039,6 +1050,7 @@ export function FolderProvider({
clearSelections,
serverURL,
routes.api,
folderFieldName,
t,
getFolderData,
getEntityConfig,
@@ -1112,6 +1124,7 @@ export function FolderProvider({
focusedRowIndex,
folderCollectionConfig,
folderCollectionSlug,
folderCollectionSlugs,
folderFieldName,
folderID: activeFolderID,
getSelectedItems,

View File

@@ -70,6 +70,7 @@ export function DefaultBrowseByFolderView(
filterItems,
focusedRowIndex,
folderCollectionConfig,
folderFieldName,
folderID,
getSelectedItems,
isDragging,
@@ -133,7 +134,7 @@ export function DefaultBrowseByFolderView(
const collectionConfig = getEntityConfig({ collectionSlug })
void addItems([
formatFolderOrDocumentItem({
folderFieldName: config.folders.fieldName,
folderFieldName,
isUpload: Boolean(collectionConfig?.upload),
relationTo: collectionSlug,
useAsTitle: collectionConfig.admin.useAsTitle,
@@ -141,7 +142,7 @@ export function DefaultBrowseByFolderView(
}),
])
},
[getEntityConfig, addItems, config.folders.fieldName],
[getEntityConfig, addItems, folderFieldName],
)
const selectedItemKeys = React.useMemo(() => {
@@ -157,12 +158,15 @@ export function DefaultBrowseByFolderView(
)
}, [getSelectedItems])
const handleSetViewType = React.useCallback((view: 'grid' | 'list') => {
void setPreference('browse-by-folder', {
viewPreference: view,
})
setActiveView(view)
}, [])
const handleSetViewType = React.useCallback(
(view: 'grid' | 'list') => {
void setPreference('browse-by-folder', {
viewPreference: view,
})
setActiveView(view)
},
[setPreference],
)
React.useEffect(() => {
if (!drawerDepth) {

View File

@@ -41,6 +41,8 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
const {
clearSelections,
currentFolder,
folderCollectionSlug,
folderFieldName,
folderID,
getSelectedItems,
moveToFolder,
@@ -71,7 +73,7 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
const count = items.length
const singleNonFolderCollectionSelected =
Object.keys(groupedSelections).length === 1 &&
Object.keys(groupedSelections)[0] !== config.folders.slug
Object.keys(groupedSelections)[0] !== folderCollectionSlug
const collectionConfig = singleNonFolderCollectionSelected
? config.collections.find((collection) => {
return collection.slug === Object.keys(groupedSelections)[0]
@@ -146,6 +148,8 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
<MoveItemsToFolderDrawer
action="moveItemsToFolder"
drawerSlug={moveToFolderDrawerSlug}
folderCollectionSlug={folderCollectionSlug}
folderFieldName={folderFieldName}
fromFolderID={folderID}
fromFolderName={currentFolder?.value?._folderOrDocumentTitle}
itemsToMove={getSelectedItems()}

View File

@@ -64,6 +64,8 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) {
filterItems,
focusedRowIndex,
folderCollectionConfig,
folderCollectionSlug,
folderFieldName,
getSelectedItems,
isDragging,
lastSelectedIndex,
@@ -110,7 +112,7 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) {
const collectionConfig = getEntityConfig({ collectionSlug })
void addItems([
formatFolderOrDocumentItem({
folderFieldName: config.folders.fieldName,
folderFieldName,
isUpload: Boolean(collectionConfig?.upload),
relationTo: collectionSlug,
useAsTitle: collectionConfig.admin.useAsTitle,
@@ -118,7 +120,7 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) {
}),
])
},
[getEntityConfig, addItems, config.folders.fieldName],
[getEntityConfig, addItems, folderFieldName],
)
const selectedItemKeys = React.useMemo(() => {
@@ -134,12 +136,15 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) {
)
}, [getSelectedItems])
const handleSetViewType = React.useCallback((view: 'grid' | 'list') => {
void setPreference(`${collectionSlug}-collection-folder`, {
viewPreference: view,
})
setActiveView(view)
}, [])
const handleSetViewType = React.useCallback(
(view: 'grid' | 'list') => {
void setPreference(`${collectionSlug}-collection-folder`, {
viewPreference: view,
})
setActiveView(view)
},
[collectionSlug, setPreference],
)
React.useEffect(() => {
if (!drawerDepth) {
@@ -213,11 +218,14 @@ export function DefaultCollectionFolderView(props: FolderListViewClientProps) {
key="list-selection"
/>
),
<ListFolderPills
collectionConfig={collectionConfig}
key="list-header-buttons"
viewType="folders"
/>,
config.folders && collectionConfig.folders && (
<ListFolderPills
collectionConfig={collectionConfig}
folderCollectionSlug={folderCollectionSlug}
key="list-header-buttons"
viewType="folders"
/>
),
].filter(Boolean)}
AfterListHeaderContent={Description}
title={getTranslation(labels?.plural, i18n)}

View File

@@ -107,9 +107,10 @@ export const CollectionListHeader: React.FC<ListHeaderProps> = ({
label={getTranslation(collectionConfig?.labels?.plural, i18n)}
/>
),
collectionConfig.folders && (
collectionConfig.folders && config.folders && (
<ListFolderPills
collectionConfig={collectionConfig}
folderCollectionSlug={config.folders.slug}
key="list-header-buttons"
viewType={viewType}
/>

View File

@@ -65,6 +65,9 @@ export const testEslintConfig = [
'runFilterOptionsTest',
'assertNetworkRequests',
'assertRequestBody',
'expectNoResultsAndCreateFolderButton',
'createFolder',
'createFolderFromDoc',
],
},
],

View File

@@ -0,0 +1,17 @@
import type { CollectionConfig } from 'payload'
import { postSlug } from '../../shared.js'
export const Posts: CollectionConfig = {
slug: postSlug,
admin: {
useAsTitle: 'title',
},
folders: true,
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,49 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { Posts } from './collections/Posts/index.js'
export default buildConfigWithDefaults({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
folders: {
// debug: true,
collectionOverrides: [
({ collection }) => {
return collection
},
],
browseByFolder: false,
},
collections: [Posts],
globals: [
{
slug: 'global',
fields: [
{
name: 'title',
type: 'text',
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
// await seed(payload)
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,44 @@
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 } from '../helpers.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
test.describe('Browse By Folders Disabled', () => {
let page: Page
let serverURL: string
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
const { serverURL: serverFromInit } = await initPayloadE2ENoConfig({ dirname })
serverURL = serverFromInit
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
test.beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'BrowseByFoldersDisabledTest',
})
})
test('should not show the browse-by-folder button in the nav', async () => {
await page.goto(`${serverURL}/admin`)
await page.locator('#nav-toggler button.nav-toggler').click()
await expect(page.locator('#nav-toggler button.nav-toggler--is-open')).toBeVisible()
await expect(page.locator('.browse-by-folder-button')).toBeHidden()
})
})

View File

@@ -0,0 +1,195 @@
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))
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
beforeEach(async () => {
await payload.delete({
collection: 'posts',
depth: 0,
where: {
id: {
exists: true,
},
},
})
await payload.delete({
collection: 'payload-folders',
depth: 0,
where: {
id: {
exists: true,
},
},
})
})
describe('folder > subfolder querying', () => {
it('should populate subfolders for folder by ID', async () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
},
})
const folderIDFromParams = parentFolder.id
await payload.create({
collection: 'payload-folders',
data: {
name: 'Nested 1',
folder: folderIDFromParams,
},
})
await payload.create({
collection: 'payload-folders',
data: {
name: 'Nested 2',
folder: folderIDFromParams,
},
})
const parentFolderQuery = await payload.findByID({
collection: 'payload-folders',
id: folderIDFromParams,
})
expect(parentFolderQuery.documentsAndFolders.docs).toHaveLength(2)
})
})
describe('folder > file querying', () => {
it('should populate files for folder by ID', async () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
},
})
const folderIDFromParams = parentFolder.id
await payload.create({
collection: 'posts',
data: {
title: 'Post 1',
folder: folderIDFromParams,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Post 2',
folder: folderIDFromParams,
},
})
const parentFolderQuery = await payload.findByID({
collection: 'payload-folders',
id: folderIDFromParams,
})
expect(parentFolderQuery.documentsAndFolders.docs).toHaveLength(2)
})
})
describe('hooks', () => {
it('reparentChildFolder should change the child after updating the parent', async () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
},
})
const childFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Child Folder',
folder: parentFolder,
},
})
await payload.update({
collection: 'payload-folders',
data: { folder: childFolder },
id: parentFolder.id,
})
const parentAfter = await payload.findByID({
collection: 'payload-folders',
id: parentFolder.id,
depth: 0,
})
const childAfter = await payload.findByID({
collection: 'payload-folders',
id: childFolder.id,
depth: 0,
})
expect(childAfter.folder).toBeFalsy()
expect(parentAfter.folder).toBe(childFolder.id)
})
it('dissasociateAfterDelete should delete _folder value in children after deleting the folder', async () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
},
})
const post = await payload.create({ collection: 'posts', data: { folder: parentFolder } })
await payload.delete({ collection: 'payload-folders', id: parentFolder.id })
const postAfter = await payload.findByID({ collection: 'posts', id: post.id })
expect(postAfter.folder).toBeFalsy()
})
it('deleteSubfoldersBeforeDelete deletes subfolders after deleting the parent folder', async () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
},
})
const childFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Child Folder',
folder: parentFolder,
},
})
await payload.delete({ collection: 'payload-folders', id: parentFolder.id })
await expect(
payload.findByID({
collection: 'payload-folders',
id: childFolder.id,
disableErrors: true,
}),
).resolves.toBeNull()
})
})
})

View File

@@ -0,0 +1,340 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
posts: Post;
users: User;
'payload-folders': FolderInterface;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {
'payload-folders': {
documentsAndFolders: 'payload-folders' | 'posts';
};
};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-folders': PayloadFoldersSelect<false> | PayloadFoldersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {
global: Global;
};
globalsSelect: {
global: GlobalSelect<false> | GlobalSelect<true>;
};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title?: string | null;
folder?: (string | null) | FolderInterface;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-folders".
*/
export interface FolderInterface {
id: string;
name: string;
folder?: (string | null) | FolderInterface;
documentsAndFolders?: {
docs?: (
| {
relationTo?: 'payload-folders';
value: string | FolderInterface;
}
| {
relationTo?: 'posts';
value: string | Post;
}
)[];
hasNextPage?: boolean;
totalDocs?: number;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null)
| ({
relationTo: 'payload-folders';
value: string | FolderInterface;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
folder?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-folders_select".
*/
export interface PayloadFoldersSelect<T extends boolean = true> {
name?: T;
folder?: T;
documentsAndFolders?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global".
*/
export interface Global {
id: string;
title?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global_select".
*/
export interface GlobalSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

View File

@@ -0,0 +1 @@
export const postSlug = 'posts'

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,23 @@
import type { CollectionConfig } from 'payload'
import { omittedFromBrowseBySlug } from '../../shared.js'
export const OmittedFromBrowseBy: CollectionConfig = {
slug: omittedFromBrowseBySlug,
labels: {
singular: 'Omitted From Browse By',
plural: 'Omitted From Browse By',
},
admin: {
useAsTitle: 'title',
},
folders: {
browseByFolder: false,
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -7,8 +7,9 @@ import { devUser } from '../credentials.js'
import { Autosave } from './collections/Autosave/index.js'
import { Drafts } from './collections/Drafts/index.js'
import { Media } from './collections/Media/index.js'
import { OmittedFromBrowseBy } from './collections/OmittedFromBrowseBy/index.js'
import { Posts } from './collections/Posts/index.js'
import { seed } from './seed/index.js'
// import { seed } from './seed/index.js'
export default buildConfigWithDefaults({
admin: {
@@ -18,8 +19,13 @@ export default buildConfigWithDefaults({
},
folders: {
// debug: true,
collectionOverrides: [
({ collection }) => {
return collection
},
],
},
collections: [Posts, Media, Drafts, Autosave],
collections: [Posts, Media, Drafts, Autosave, OmittedFromBrowseBy],
globals: [
{
slug: 'global',

View File

@@ -7,9 +7,15 @@ import { fileURLToPath } from 'url'
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { clickFolderCard } from '../helpers/folders/clickFolderCard.js'
import { createFolder } from '../helpers/folders/createFolder.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 { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { postSlug } from './shared.js'
import { omittedFromBrowseBySlug, postSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -17,6 +23,7 @@ const dirname = path.dirname(filename)
test.describe('Folders', () => {
let page: Page
let postURL: AdminUrlUtil
let OmittedFromBrowseBy: AdminUrlUtil
let serverURL: string
test.beforeAll(async ({ browser }, testInfo) => {
@@ -25,6 +32,7 @@ test.describe('Folders', () => {
const { serverURL: serverFromInit } = await initPayloadE2ENoConfig({ dirname })
serverURL = serverFromInit
postURL = new AdminUrlUtil(serverURL, postSlug)
OmittedFromBrowseBy = new AdminUrlUtil(serverURL, omittedFromBrowseBySlug)
const context = await browser.newContext()
page = await context.newPage()
@@ -40,31 +48,29 @@ test.describe('Folders', () => {
})
test.describe('No folders', () => {
/* eslint-disable playwright/expect-expect */
test('should show no results and create button in folder view', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await expectNoResultsAndCreateFolderButton()
await expectNoResultsAndCreateFolderButton({ page })
})
/* eslint-disable playwright/expect-expect */
test('should show no results and create button in document view', async () => {
await page.goto(postURL.create)
const folderButton = page.getByRole('button', { name: 'No Folder' })
await expect(folderButton).toBeVisible()
await folderButton.click()
await expectNoResultsAndCreateFolderButton()
await expectNoResultsAndCreateFolderButton({ page })
})
})
test.describe('Creating folders', () => {
test('should create new folder from folder view', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('New Folder From Root')
await createFolder({ folderName: 'New Folder From Root', page })
})
/* eslint-disable playwright/expect-expect */
test('should create new folder from collection view', async () => {
await page.goto(postURL.byFolder)
await createFolder('New Folder From Collection', true)
await createFolder({ folderName: 'New Folder From Collection', fromDropdown: true, page })
})
test('should create new folder from document view', async () => {
@@ -72,7 +78,7 @@ test.describe('Folders', () => {
await createPostWithNoFolder()
const folderPill = page.locator('.doc-controls .move-doc-to-folder', { hasText: 'No Folder' })
await folderPill.click()
await createFolderFromDoc('New Folder From Doc')
await createFolderFromDoc({ folderName: 'New Folder From Doc', page })
})
})
@@ -85,8 +91,8 @@ test.describe('Folders', () => {
test('should rename folder', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Test Folder')
await clickFolderCard('Test Folder')
await createFolder({ folderName: 'Test Folder', page })
await clickFolderCard({ folderName: 'Test Folder', page })
const renameButton = page.locator('.list-selection__actions button', {
hasText: 'Rename',
})
@@ -108,8 +114,8 @@ test.describe('Folders', () => {
test('should delete folder', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Delete This Folder')
await clickFolderCard('Delete This Folder')
await createFolder({ folderName: 'Delete This Folder', page })
await clickFolderCard({ folderName: 'Delete This Folder', page })
const deleteButton = page.locator('.list-selection__actions button', {
hasText: 'Delete',
})
@@ -125,12 +131,12 @@ test.describe('Folders', () => {
test('should delete folder but not delete documents', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Folder With Documents')
await createFolder({ folderName: 'Folder With Documents', page })
await createPostWithExistingFolder('Document 1', 'Folder With Documents')
await createPostWithExistingFolder('Document 2', 'Folder With Documents')
await page.goto(`${serverURL}/admin/browse-by-folder`)
await clickFolderCard('Folder With Documents')
await clickFolderCard({ folderName: 'Folder With Documents', page })
const deleteButton = page.locator('.list-selection__actions button', {
hasText: 'Delete',
})
@@ -152,9 +158,9 @@ test.describe('Folders', () => {
test('should move folder', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Move Into This Folder')
await createFolder('Move Me')
await clickFolderCard('Move Me')
await createFolder({ folderName: 'Move Into This Folder', page })
await createFolder({ folderName: 'Move Me', page })
await clickFolderCard({ folderName: 'Move Me', page })
const moveButton = page.locator('.list-selection__actions button', {
hasText: 'Move',
})
@@ -187,8 +193,8 @@ 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('Create New Here')
await clickFolderCard('Create New Here', true)
await createFolder({ folderName: 'Create New Here', page })
await clickFolderCard({ folderName: 'Create New Here', page, doubleClick: true })
const createDocButton = page.locator('.create-new-doc-in-folder__popup-button', {
hasText: 'Create document',
})
@@ -212,11 +218,10 @@ test.describe('Folders', () => {
await expect(folderCard).toBeVisible()
})
// this test currently fails in postgres
test('should create nested folder from folder view', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Parent Folder')
await clickFolderCard('Parent Folder', true)
await createFolder({ folderName: 'Parent Folder', page })
await clickFolderCard({ folderName: 'Parent Folder', page, doubleClick: true })
const pageTitle = page.locator('h1.list-header__title')
await expect(pageTitle).toHaveText('Parent Folder')
@@ -248,7 +253,7 @@ test.describe('Folders', () => {
test('should toggle between grid and list view', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Test Folder')
await createFolder({ folderName: 'Test Folder', page })
const listViewButton = page.locator('.folder-view-toggle-button').nth(1)
await listViewButton.click()
const listView = page.locator('.simple-table')
@@ -262,9 +267,9 @@ test.describe('Folders', () => {
test('should sort folders', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('A Folder')
await createFolder('B Folder')
await createFolder('C Folder')
await createFolder({ folderName: 'A Folder', page })
await createFolder({ folderName: 'B Folder', page })
await createFolder({ folderName: 'C Folder', page })
const firstFolderCard = page.locator('.folder-file-card__name').first()
await expect(firstFolderCard).toHaveText('A Folder')
@@ -282,8 +287,8 @@ test.describe('Folders', () => {
test('should allow filtering within folders', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Filtering Folder')
await clickFolderCard('Filtering Folder', true)
await createFolder({ folderName: 'Filtering Folder', page })
await clickFolderCard({ folderName: 'Filtering Folder', page, doubleClick: true })
const createNewDropdown = page.locator('.create-new-doc-in-folder__popup-button', {
hasText: 'Create New',
@@ -328,8 +333,8 @@ test.describe('Folders', () => {
test('should allow searching within folders', async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Test')
await createFolder('Search Me')
await createFolder({ folderName: 'Test', page })
await createFolder({ folderName: 'Search Me', page })
const testFolderCard = page.locator('.folder-file-card__name', {
hasText: 'Test',
@@ -351,7 +356,7 @@ test.describe('Folders', () => {
test.describe('Collection view actions', () => {
test.beforeEach(async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Move Into This Folder')
await createFolder({ folderName: 'Move Into This Folder', page })
await createPostWithNoFolder()
await page.goto(postURL.list)
})
@@ -375,33 +380,33 @@ test.describe('Folders', () => {
})
test('should update folder from doc folder pill', async () => {
await selectFolderAndConfirmMoveFromList('Move Into This Folder')
await selectFolderAndConfirmMoveFromList({ folderName: 'Move Into This Folder', page })
await expect(page.locator('.payload-toast-container')).toContainText(
'Test Post has been moved',
)
})
test('should resolve folder pills and not get stuck as Loading...', async () => {
await selectFolderAndConfirmMoveFromList('Move Into This Folder')
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 () => {
const folderPill = page.locator('tbody .row-1 .move-doc-to-folder')
await selectFolderAndConfirmMoveFromList('Move Into This Folder')
await selectFolderAndConfirmMoveFromList({ folderName: 'Move Into This Folder', page })
await expect(folderPill).toHaveText('Move Into This Folder')
})
test('should show updated folder pill after removing doc folder', async () => {
const folderPill = page.locator('tbody .row-1 .move-doc-to-folder')
await selectFolderAndConfirmMoveFromList('Move Into This 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()
await selectFolderAndConfirmMove()
await selectFolderAndConfirmMove({ page })
await expect(folderPill).toHaveText('No Folder')
})
@@ -428,7 +433,7 @@ test.describe('Folders', () => {
test.describe('Document view actions', () => {
test.beforeEach(async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Test Folder')
await createFolder({ folderName: 'Test Folder', page })
await createPostWithNoFolder()
})
@@ -440,7 +445,7 @@ test.describe('Folders', () => {
test('should update folder from folder pill in doc controls', async () => {
const folderPill = page.locator('.doc-controls .move-doc-to-folder', { hasText: 'No Folder' })
await folderPill.click()
await clickFolderCard('Test Folder', true)
await clickFolderCard({ folderName: 'Test Folder', doubleClick: true, page })
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })
@@ -453,7 +458,7 @@ test.describe('Folders', () => {
test('should show updated folder pill after folder change', async () => {
const folderPill = page.locator('.doc-controls .move-doc-to-folder', { hasText: 'No Folder' })
await folderPill.click()
await clickFolderCard('Test Folder', true)
await clickFolderCard({ folderName: 'Test Folder', doubleClick: true, page })
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })
@@ -468,9 +473,9 @@ test.describe('Folders', () => {
test.describe('Multiple select options', () => {
test.beforeEach(async () => {
await page.goto(`${serverURL}/admin/browse-by-folder`)
await createFolder('Test Folder 1')
await createFolder('Test Folder 2')
await createFolder('Test Folder 3')
await createFolder({ folderName: 'Test Folder 1', page })
await createFolder({ folderName: 'Test Folder 2', page })
await createFolder({ folderName: 'Test Folder 3', page })
})
test('should show how many folders are selected', async () => {
@@ -513,7 +518,7 @@ test.describe('Folders', () => {
await expect(firstFolderCard).toBeHidden()
})
test('should move multiple folders', async () => {
await createFolder('Move into here')
await createFolder({ folderName: 'Move into here', page })
const firstFolderCard = page.locator('.folder-file-card', {
hasText: 'Test Folder 1',
})
@@ -534,121 +539,58 @@ test.describe('Folders', () => {
hasText: 'Move into here',
})
await destinationFolder.click()
await selectFolderAndConfirmMove()
await selectFolderAndConfirmMove({ page })
await expect(page.locator('.payload-toast-container')).toContainText('moved')
await expect(firstFolderCard).toBeHidden()
})
})
async function expectNoResultsAndCreateFolderButton() {
const noResultsDiv = page.locator('div.no-results')
await expect(noResultsDiv).toBeVisible()
const createFolderButton = page.locator('text=Create Folder')
await expect(createFolderButton).toBeVisible()
}
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 })
async function createFolder(folderName: string, fromDropdown: boolean = false) {
if (fromDropdown) {
const folderDropdown = page.locator('.create-new-doc-in-folder__popup-button', {
hasText: 'Create',
})
await folderDropdown.click()
const createFolderButton = page.locator('.popup-button-list__button', {
hasText: 'Folder',
})
await createFolderButton.click()
} else {
const createFolderButton = page.locator(
'.create-new-doc-in-folder__button:has-text("Create New")',
)
await createFolderButton.click()
}
// create document
await page.goto(OmittedFromBrowseBy.create)
const titleInput = page.locator('input[name="title"]')
await titleInput.fill('Omitted Doc')
await saveDocAndAssert(page)
const folderNameInput = page.locator(
'dialog#create-document--header-pill-new-folder-drawer div.drawer-content-container input#field-name',
)
// 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 folderNameInput.fill(folderName)
// go to browse by folder view
await page.goto(`${serverURL}/admin/browse-by-folder`)
await clickFolderCard({ folderName, page, doubleClick: true })
const createButton = page.getByRole('button', { name: 'Apply Changes' })
await createButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
const folderCard = page.locator('.folder-file-card__name', { hasText: folderName }).first()
await expect(folderCard).toBeVisible()
}
async function createFolderFromDoc(folderName: string) {
const addFolderButton = page.locator('.create-new-doc-in-folder__button', {
hasText: 'Create folder',
// folder should be empty
await expectNoResultsAndCreateFolderButton({ page })
})
await addFolderButton.click()
const folderNameInput = page.locator('div.drawer-content-container input#field-name')
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 folderNameInput.fill(folderName)
await page.locator('button:has(.collection-type__count)').click()
const createButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Create' })
await createButton.click()
await expect(
page.locator('.checkbox-input .field-label', {
hasText: 'Omitted From Browse By',
}),
).toBeHidden()
})
})
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
const folderCard = page.locator('.folder-file-card__name', { hasText: folderName }).first()
await expect(folderCard).toBeVisible()
}
async function clickFolderCard(folderName: string, doubleClick: boolean = false) {
const folderCard = page
.locator('.folder-file-card')
.filter({
has: page.locator('.folder-file-card__name', { hasText: folderName }),
})
.first()
const dragHandleButton = folderCard.locator('div[role="button"].folder-file-card__drag-handle')
if (doubleClick) {
await dragHandleButton.dblclick()
} else {
await dragHandleButton.click()
}
}
async function selectFolderAndConfirmMoveFromList(folderName: string | undefined = undefined) {
const firstListItem = page.locator('tbody .row-1')
const folderPill = firstListItem.locator('.move-doc-to-folder')
await folderPill.click()
if (folderName) {
await clickFolderCard(folderName, true)
}
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })
await selectButton.click()
const confirmMoveButton = page
.locator('dialog#move-folder-drawer-confirm-move')
.getByRole('button', { name: 'Move' })
await confirmMoveButton.click()
}
async function selectFolderAndConfirmMove(folderName: string | undefined = undefined) {
if (folderName) {
await clickFolderCard(folderName, true)
}
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })
await selectButton.click()
const confirmMoveButton = page
.locator('dialog#move-folder-drawer-confirm-move')
.getByRole('button', { name: 'Move' })
await confirmMoveButton.click()
}
// Helper functions
async function createPostWithNoFolder() {
await page.goto(postURL.create)
@@ -664,7 +606,7 @@ test.describe('Folders', () => {
await saveDocAndAssert(page)
const folderPill = page.locator('.doc-controls .move-doc-to-folder', { hasText: 'No Folder' })
await folderPill.click()
await clickFolderCard(folderName)
await clickFolderCard({ folderName, page })
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })

View File

@@ -71,6 +71,7 @@ export interface Config {
media: Media;
drafts: Draft;
autosave: Autosave;
'omitted-from-browse-by': OmittedFromBrowseBy;
users: User;
'payload-folders': FolderInterface;
'payload-locked-documents': PayloadLockedDocument;
@@ -79,7 +80,7 @@ export interface Config {
};
collectionsJoins: {
'payload-folders': {
documentsAndFolders: 'payload-folders' | 'posts' | 'media' | 'drafts' | 'autosave';
documentsAndFolders: 'payload-folders' | 'posts' | 'media' | 'drafts' | 'autosave' | 'omitted-from-browse-by';
};
};
collectionsSelect: {
@@ -87,6 +88,7 @@ export interface Config {
media: MediaSelect<false> | MediaSelect<true>;
drafts: DraftsSelect<false> | DraftsSelect<true>;
autosave: AutosaveSelect<false> | AutosaveSelect<true>;
'omitted-from-browse-by': OmittedFromBrowseBySelect<false> | OmittedFromBrowseBySelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-folders': PayloadFoldersSelect<false> | PayloadFoldersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -191,6 +193,10 @@ export interface FolderInterface {
relationTo?: 'autosave';
value: string | Autosave;
}
| {
relationTo?: 'omitted-from-browse-by';
value: string | OmittedFromBrowseBy;
}
)[];
hasNextPage?: boolean;
totalDocs?: number;
@@ -222,6 +228,17 @@ export interface Autosave {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "omitted-from-browse-by".
*/
export interface OmittedFromBrowseBy {
id: string;
title?: string | null;
folder?: (string | null) | FolderInterface;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -262,6 +279,10 @@ export interface PayloadLockedDocument {
relationTo: 'autosave';
value: string | Autosave;
} | null)
| ({
relationTo: 'omitted-from-browse-by';
value: string | OmittedFromBrowseBy;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -364,6 +385,16 @@ export interface AutosaveSelect<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "omitted-from-browse-by_select".
*/
export interface OmittedFromBrowseBySelect<T extends boolean = true> {
title?: T;
folder?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -1 +1,2 @@
export const postSlug = 'posts'
export const omittedFromBrowseBySlug = 'omitted-from-browse-by'

View File

@@ -0,0 +1,27 @@
import type { Page } from '@playwright/test'
type Args = {
doubleClick?: boolean
folderName: string
page: Page
}
export async function clickFolderCard({
page,
folderName,
doubleClick = false,
}: Args): Promise<void> {
const folderCard = page
.locator('.folder-file-card')
.filter({
has: page.locator('.folder-file-card__name', { hasText: folderName }),
})
.first()
const dragHandleButton = folderCard.locator('div[role="button"].folder-file-card__drag-handle')
if (doubleClick) {
await dragHandleButton.dblclick()
} else {
await dragHandleButton.click()
}
}

View File

@@ -0,0 +1,42 @@
import { expect, type Page } from '@playwright/test'
type Args = {
folderName: string
fromDropdown?: boolean
page: Page
}
export async function createFolder({
folderName,
fromDropdown = false,
page,
}: Args): Promise<void> {
if (fromDropdown) {
const folderDropdown = page.locator('.create-new-doc-in-folder__popup-button', {
hasText: 'Create',
})
await folderDropdown.click()
const createFolderButton = page.locator('.popup-button-list__button', {
hasText: 'Folder',
})
await createFolderButton.click()
} else {
const createFolderButton = page.locator(
'.create-new-doc-in-folder__button:has-text("Create New")',
)
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')
const folderCard = page.locator('.folder-file-card__name', { hasText: folderName }).first()
await expect(folderCard).toBeVisible()
}

View File

@@ -0,0 +1,27 @@
import { expect, type Page } from '@playwright/test'
type Args = {
folderName: string
page: Page
}
export async function createFolderFromDoc({ folderName, page }: Args): Promise<void> {
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')
const folderCard = page.locator('.folder-file-card__name', { hasText: folderName }).first()
await expect(folderCard).toBeVisible()
}

View File

@@ -0,0 +1,12 @@
import { expect, type Page } from '@playwright/test'
type Args = {
page: Page
}
export async function expectNoResultsAndCreateFolderButton({ page }: Args): Promise<void> {
const noResultsDiv = page.locator('div.no-results')
await expect(noResultsDiv).toBeVisible()
const createFolderButton = page.locator('text=Create Folder')
await expect(createFolderButton).toBeVisible()
}

View File

@@ -0,0 +1,22 @@
import type { Page } from '@playwright/test'
import { clickFolderCard } from './clickFolderCard.js'
type Args = {
folderName?: string
page: Page
}
export async function selectFolderAndConfirmMove({ folderName, page }: Args): Promise<void> {
if (folderName) {
await clickFolderCard({ folderName, doubleClick: true, page })
}
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })
await selectButton.click()
const confirmMoveButton = page
.locator('dialog#move-folder-drawer-confirm-move')
.getByRole('button', { name: 'Move' })
await confirmMoveButton.click()
}

View File

@@ -0,0 +1,31 @@
import type { Page } from '@playwright/test'
import { clickFolderCard } from './clickFolderCard.js'
type Args = {
folderName?: string
page: Page
rowIndex?: number
}
export async function selectFolderAndConfirmMoveFromList({
page,
folderName,
rowIndex = 1,
}: Args): Promise<void> {
const firstListItem = page.locator(`tbody .row-${rowIndex}`)
const folderPill = firstListItem.locator('.move-doc-to-folder')
await folderPill.click()
if (folderName) {
await clickFolderCard({ folderName, doubleClick: true, page })
}
const selectButton = page
.locator('button[aria-label="Apply Changes"]')
.filter({ hasText: 'Select' })
await selectButton.click()
const confirmMoveButton = page
.locator('dialog#move-folder-drawer-confirm-move')
.getByRole('button', { name: 'Move' })
await confirmMoveButton.click()
}