Files
payloadcms/test/folders/int.spec.ts
Jarrod Flesch 12539c61d4 feat(ui): supports collection scoped folders (#12797)
As discussed in [this
RFC](https://github.com/payloadcms/payload/discussions/12729), this PR
supports collection-scoped folders. You can scope folders to multiple
collection types or just one.

This unlocks the possibility to have folders on a per collection instead
of always being shared on every collection. You can combine this feature
with the `browseByFolder: false` to completely isolate a collection from
other collections.

Things left to do:
- [x] ~~Create a custom react component for the selecting of
collectionSlugs to filter out available options based on the current
folders parameters~~


https://github.com/user-attachments/assets/14cb1f09-8d70-4cb9-b1e2-09da89302995


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210564397815557
2025-07-17 13:24:22 -04:00

349 lines
9.4 KiB
TypeScript

import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
let payload: Payload
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('folders', () => {
beforeAll(async () => {
;({ payload } = await initPayloadInt(dirname))
})
afterAll(async () => {
await payload.destroy()
})
beforeEach(async () => {
await payload.delete({
collection: 'payload-folders',
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',
folderType: ['posts'],
},
})
const folderIDFromParams = parentFolder.id
await payload.create({
collection: 'payload-folders',
data: {
name: 'Nested 1',
folder: folderIDFromParams,
folderType: ['posts'],
},
})
await payload.create({
collection: 'payload-folders',
data: {
name: 'Nested 2',
folder: folderIDFromParams,
folderType: ['posts'],
},
})
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: {
folderType: ['posts'],
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: {
folderType: ['posts'],
name: 'Parent Folder',
},
})
const childFolder = await payload.create({
collection: 'payload-folders',
data: {
folderType: ['posts'],
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: {
folderType: ['posts'],
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: {
folderType: ['posts'],
name: 'Parent Folder',
},
})
const childFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Child Folder',
folder: parentFolder,
folderType: ['posts'],
},
})
await payload.delete({ collection: 'payload-folders', id: parentFolder.id })
await expect(
payload.findByID({
collection: 'payload-folders',
id: childFolder.id,
disableErrors: true,
}),
).resolves.toBeNull()
})
describe('ensureSafeCollectionsChange', () => {
it('should prevent narrowing scope of a folder if it contains documents of a removed type', async () => {
const sharedFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Posts and Drafts Folder',
folderType: ['posts', 'drafts'],
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Post 1',
folder: sharedFolder.id,
},
})
await payload.create({
collection: 'drafts',
data: {
title: 'Post 1',
folder: sharedFolder.id,
},
})
try {
const updatedFolder = await payload.update({
collection: 'payload-folders',
id: sharedFolder.id,
data: {
folderType: ['posts'],
},
})
expect(updatedFolder).not.toBeDefined()
} catch (e: any) {
expect(e.message).toBe(
'The folder "Posts and Drafts Folder" contains documents that still belong to the following collections: Drafts',
)
}
})
it('should prevent adding scope to a folder if it contains documents outside of the new scope', async () => {
const folderAcceptsAnything = await payload.create({
collection: 'payload-folders',
data: {
name: 'Anything Goes',
folderType: [],
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Post 1',
folder: folderAcceptsAnything.id,
},
})
try {
const scopedFolder = await payload.update({
collection: 'payload-folders',
id: folderAcceptsAnything.id,
data: {
folderType: ['posts'],
},
})
expect(scopedFolder).not.toBeDefined()
} catch (e: any) {
expect(e.message).toBe(
'The folder "Anything Goes" contains documents that still belong to the following collections: Posts',
)
}
})
it('should prevent narrowing scope of a folder if subfolders are assigned to any of the removed types', async () => {
const parentFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
folderType: ['posts', 'drafts'],
},
})
await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
folderType: ['posts', 'drafts'],
folder: parentFolder.id,
},
})
try {
const updatedParent = await payload.update({
collection: 'payload-folders',
id: parentFolder.id,
data: {
folderType: ['posts'],
},
})
expect(updatedParent).not.toBeDefined()
} catch (e: any) {
expect(e.message).toBe(
'The folder "Parent Folder" contains folders that still belong to the following collections: Drafts',
)
}
})
it('should prevent widening scope on a scoped subfolder', async () => {
const unscopedFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Parent Folder',
folderType: [],
},
})
const level1Folder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Level 1 Folder',
folderType: ['posts', 'drafts'],
folder: unscopedFolder.id,
},
})
try {
const level2UnscopedFolder = await payload.create({
collection: 'payload-folders',
data: {
name: 'Level 2 Folder',
folder: level1Folder.id,
folderType: [],
},
})
expect(level2UnscopedFolder).not.toBeDefined()
} catch (e: any) {
expect(e.message).toBe(
'The folder "Level 2 Folder" must have folder-type set since its parent folder "Level 1 Folder" has a folder-type set.',
)
}
})
})
})
})