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',
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```
This commit is contained in:
Jarrod Flesch
2025-08-26 16:44:57 -04:00
committed by GitHub
parent 1dc346af04
commit 138938ec55
8 changed files with 214 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<false> | PostsSelect<true>;
tabs: TabsSelect<false> | TabsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -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<T extends boolean = true> {
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tabs_select".
*/
export interface TabsSelect<T extends boolean = true> {
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".

View File

@@ -1 +1,3 @@
export const postsSlug = 'posts'
export const tabsSlug = 'tabs'

View File

@@ -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,33 +27,43 @@ export const addListFilter = async ({
await whereBuilder.locator('.where-builder__add-first-filter').click()
const conditionField = whereBuilder.locator('.condition__field')
await conditionField.click()
await conditionField
.locator('.rs__option', {
hasText: exactText(fieldLabel),
await selectInput({
selectLocator: whereBuilder.locator('.condition__field'),
multiSelect: false,
option: 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 }
}

View File

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