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