From 138938ec5518ec591e4de070dd97e35886f26aaa Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:44:57 -0400 Subject: [PATCH] fix(ui): bulk edit overwriting fields within named tabs (#13600) Fixes https://github.com/payloadcms/payload/issues/13429 Having a config like the following would remove data from the nested tabs array field when bulk editing. ```ts { type: 'tabs', tabs: [ { label: 'Tabs Tabs Array', fields: [ { type: 'tabs', tabs: [ { name: 'tabTab', fields: [ { name: 'tabTabArray', type: 'array', fields: [ { name: 'tabTabArrayText', type: 'text', } ] } ] } ] } ] } ] } ``` --- .../addFieldStatePromise.ts | 24 +++++-- test/bulk-edit/collections/Tabs/index.ts | 46 ++++++++++++ test/bulk-edit/config.ts | 3 +- test/bulk-edit/e2e.spec.ts | 70 ++++++++++++++++++- test/bulk-edit/payload-types.ts | 43 ++++++++++++ test/bulk-edit/shared.ts | 2 + test/helpers/e2e/addListFilter.ts | 50 +++++++------ test/helpers/e2e/selectInput.ts | 4 +- 8 files changed, 214 insertions(+), 28 deletions(-) create mode 100644 test/bulk-edit/collections/Tabs/index.ts diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 3ad96b3db0..969673aa48 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -15,11 +15,12 @@ import type { SanitizedFieldsPermissions, SelectMode, SelectType, + TabAsField, Validate, } from 'payload' import ObjectIdImport from 'bson-objectid' -import { getBlockSelect } from 'payload' +import { getBlockSelect, stripUnselectedFields } from 'payload' import { deepCopyObjectSimple, fieldAffectsData, @@ -811,15 +812,17 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom const isNamedTab = tabHasName(tab) let tabSelect: SelectType | undefined + const tabField: TabAsField = { + ...tab, + type: 'tab', + } + const { indexPath: tabIndexPath, path: tabPath, schemaPath: tabSchemaPath, } = getFieldPaths({ - field: { - ...tab, - type: 'tab', - }, + field: tabField, index: tabIndex, parentIndexPath: indexPath, parentPath, @@ -829,6 +832,17 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom let childPermissions: SanitizedFieldsPermissions = undefined if (isNamedTab) { + const shouldContinue = stripUnselectedFields({ + field: tabField, + select, + selectMode, + siblingDoc: data?.[tab.name] || {}, + }) + + if (!shouldContinue) { + return + } + if (parentPermissions === true) { childPermissions = true } else { diff --git a/test/bulk-edit/collections/Tabs/index.ts b/test/bulk-edit/collections/Tabs/index.ts new file mode 100644 index 0000000000..15f38d74b2 --- /dev/null +++ b/test/bulk-edit/collections/Tabs/index.ts @@ -0,0 +1,46 @@ +import type { CollectionConfig } from 'payload' + +import { tabsSlug } from '../../shared.js' + +export const TabsCollection: CollectionConfig = { + slug: tabsSlug, + admin: { + useAsTitle: 'title', + }, + fields: [ + { + type: 'text', + name: 'title', + }, + { + type: 'tabs', + tabs: [ + { + label: 'Tabs Tabs Array', + fields: [ + { + type: 'tabs', + tabs: [ + { + name: 'tabTab', + fields: [ + { + name: 'tabTabArray', + type: 'array', + fields: [ + { + name: 'tabTabArrayText', + type: 'text', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +} diff --git a/test/bulk-edit/config.ts b/test/bulk-edit/config.ts index 6886682bda..340afe6b9d 100644 --- a/test/bulk-edit/config.ts +++ b/test/bulk-edit/config.ts @@ -4,13 +4,14 @@ import path from 'path' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' import { PostsCollection } from './collections/Posts/index.js' +import { TabsCollection } from './collections/Tabs/index.js' import { postsSlug } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) export default buildConfigWithDefaults({ - collections: [PostsCollection], + collections: [PostsCollection, TabsCollection], admin: { importMap: { baseDir: path.resolve(dirname), diff --git a/test/bulk-edit/e2e.spec.ts b/test/bulk-edit/e2e.spec.ts index 49a84a88ca..01ec5e8b46 100644 --- a/test/bulk-edit/e2e.spec.ts +++ b/test/bulk-edit/e2e.spec.ts @@ -2,7 +2,9 @@ import type { BrowserContext, Locator, Page } from '@playwright/test' import type { PayloadTestSDK } from 'helpers/sdk/index.js' import { expect, test } from '@playwright/test' +import { addListFilter } from 'helpers/e2e/addListFilter.js' import { addArrayRow } from 'helpers/e2e/fields/array/index.js' +import { selectInput } from 'helpers/e2e/selectInput.js' import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js' import * as path from 'path' import { wait } from 'payload/shared' @@ -21,7 +23,7 @@ import { import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { TEST_TIMEOUT_LONG } from '../playwright.config.js' -import { postsSlug } from './shared.js' +import { postsSlug, tabsSlug } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -33,11 +35,13 @@ let serverURL: string test.describe('Bulk Edit', () => { let page: Page let postsUrl: AdminUrlUtil + let tabsUrl: AdminUrlUtil test.beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) postsUrl = new AdminUrlUtil(serverURL, postsSlug) + tabsUrl = new AdminUrlUtil(serverURL, tabsSlug) context = await browser.newContext() page = await context.newPage() @@ -616,6 +620,70 @@ test.describe('Bulk Edit', () => { ).toBeHidden() } }) + + test('should not delete nested un-named tab array data', async () => { + const originalDoc = await payload.create({ + collection: tabsSlug, + data: { + title: 'Tab Title', + tabTab: { + tabTabArray: [ + { + tabTabArrayText: 'nestedText', + }, + ], + }, + }, + }) + + await page.goto(tabsUrl.list) + await addListFilter({ + page, + fieldLabel: 'ID', + operatorLabel: 'equals', + value: originalDoc.id, + skipValueInput: false, + }) + + // select first item + await page.locator('table tbody tr.row-1 input[type="checkbox"]').check() + // open bulk edit drawer + await page + .locator('.list-selection__actions .btn', { + hasText: 'Edit', + }) + .click() + + const bulkEditForm = page.locator('form.edit-many__form') + await expect(bulkEditForm).toBeVisible() + + await selectInput({ + selectLocator: bulkEditForm.locator('.react-select'), + options: ['Title'], + multiSelect: true, + }) + + await bulkEditForm.locator('#field-title').fill('Updated Tab Title') + await bulkEditForm.locator('button[type="submit"]').click() + + await expect(bulkEditForm).toBeHidden() + + const updatedDocQuery = await payload.find({ + collection: tabsSlug, + where: { + id: { + equals: originalDoc.id, + }, + }, + }) + const updatedDoc = updatedDocQuery.docs[0] + await expect.poll(() => updatedDoc?.title).toEqual('Updated Tab Title') + await expect.poll(() => updatedDoc?.tabTab?.tabTabArray?.length).toBe(1) + + await expect + .poll(() => updatedDoc?.tabTab?.tabTabArray?.[0]?.tabTabArrayText) + .toEqual('nestedText') + }) }) async function selectFieldToEdit( diff --git a/test/bulk-edit/payload-types.ts b/test/bulk-edit/payload-types.ts index 6119b3ce52..24dc69fe80 100644 --- a/test/bulk-edit/payload-types.ts +++ b/test/bulk-edit/payload-types.ts @@ -68,6 +68,7 @@ export interface Config { blocks: {}; collections: { posts: Post; + tabs: Tab; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -76,6 +77,7 @@ export interface Config { collectionsJoins: {}; collectionsSelect: { posts: PostsSelect | PostsSelect; + tabs: TabsSelect | TabsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -154,6 +156,24 @@ export interface Post { createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tabs". + */ +export interface Tab { + id: string; + title?: string | null; + tabTab?: { + tabTabArray?: + | { + tabTabArrayText?: string | null; + id?: string | null; + }[] + | null; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -189,6 +209,10 @@ export interface PayloadLockedDocument { relationTo: 'posts'; value: string | Post; } | null) + | ({ + relationTo: 'tabs'; + value: string | Tab; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -280,6 +304,25 @@ export interface PostsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tabs_select". + */ +export interface TabsSelect { + title?: T; + tabTab?: + | T + | { + tabTabArray?: + | T + | { + tabTabArrayText?: T; + id?: T; + }; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". diff --git a/test/bulk-edit/shared.ts b/test/bulk-edit/shared.ts index f4dad31482..17adf1023c 100644 --- a/test/bulk-edit/shared.ts +++ b/test/bulk-edit/shared.ts @@ -1 +1,3 @@ export const postsSlug = 'posts' + +export const tabsSlug = 'tabs' diff --git a/test/helpers/e2e/addListFilter.ts b/test/helpers/e2e/addListFilter.ts index d8f6e9e6f3..a6320ea3cf 100644 --- a/test/helpers/e2e/addListFilter.ts +++ b/test/helpers/e2e/addListFilter.ts @@ -1,9 +1,9 @@ import type { Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' -import { exactText } from 'helpers.js' import { openListFilters } from './openListFilters.js' +import { selectInput } from './selectInput.js' export const addListFilter = async ({ page, @@ -27,32 +27,42 @@ export const addListFilter = async ({ await whereBuilder.locator('.where-builder__add-first-filter').click() - const conditionField = whereBuilder.locator('.condition__field') - await conditionField.click() + await selectInput({ + selectLocator: whereBuilder.locator('.condition__field'), + multiSelect: false, + option: fieldLabel, + }) - await conditionField - .locator('.rs__option', { - hasText: exactText(fieldLabel), - }) - ?.click() - - await expect(whereBuilder.locator('.condition__field')).toContainText(fieldLabel) - - const operatorInput = whereBuilder.locator('.condition__operator') - await operatorInput.click() - - const operatorOptions = operatorInput.locator('.rs__option') - await operatorOptions.locator(`text=${operatorLabel}`).click() + await selectInput({ + selectLocator: whereBuilder.locator('.condition__operator'), + multiSelect: false, + option: operatorLabel, + }) if (!skipValueInput) { - const valueInput = whereBuilder.locator('.condition__value >> input') + const networkPromise = page.waitForResponse( + (response) => + response.url().includes(encodeURIComponent('where[or')) && response.status() === 200, + ) + const valueLocator = whereBuilder.locator('.condition__value') + const valueInput = valueLocator.locator('input') await valueInput.fill(value) await expect(valueInput).toHaveValue(value) - const valueOptions = whereBuilder.locator('.condition__value .rs__option') - if ((await whereBuilder.locator('.condition__value >> input.rs__input').count()) > 0) { - await valueOptions.locator(`text=${value}`).click() + if ((await valueLocator.locator('input.rs__input').count()) > 0) { + const valueOptions = whereBuilder.locator('.condition__value .rs__option') + const createValue = valueOptions.locator(`text=Create "${value}"`) + if ((await createValue.count()) > 0) { + await createValue.click() + } else { + await selectInput({ + selectLocator: valueLocator, + multiSelect: false, + option: value, + }) + } } + await networkPromise } return { whereBuilder } diff --git a/test/helpers/e2e/selectInput.ts b/test/helpers/e2e/selectInput.ts index 78c8b9c00c..b219e1bc75 100644 --- a/test/helpers/e2e/selectInput.ts +++ b/test/helpers/e2e/selectInput.ts @@ -1,5 +1,7 @@ import type { Locator, Page } from '@playwright/test' +import { exactText } from 'helpers.js' + type SelectReactOptionsParams = { selectLocator: Locator // Locator for the react-select component } & ( @@ -85,7 +87,7 @@ async function selectOption({ // Find and click the desired option by visible text const optionLocator = selectLocator.locator('.rs__option', { - hasText: optionText, + hasText: exactText(optionText), }) if (optionLocator) {