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:
Jarrod Flesch
2025-07-17 13:24:22 -04:00
committed by GitHub
parent 6ae730b33b
commit 12539c61d4
102 changed files with 2127 additions and 768 deletions

View File

@@ -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)
})
})

View File

@@ -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.',
)
}
})
})
})
})

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

View 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
}
})
}

View File

@@ -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()
}
}

View File

@@ -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()

View 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')
}

View File

@@ -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()