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:
@@ -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 {
|
||||
|
||||
46
test/bulk-edit/collections/Tabs/index.ts
Normal file
46
test/bulk-edit/collections/Tabs/index.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const postsSlug = 'posts'
|
||||
|
||||
export const tabsSlug = 'tabs'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user