fix(ui): block rows unexpectedly collapse and array rows not collapsed on init (#12987)

This commit is contained in:
Jacob Fletcher
2025-06-30 21:12:26 -04:00
committed by GitHub
parent c07187d804
commit 3f30a2e300
12 changed files with 283 additions and 136 deletions

View File

@@ -28,7 +28,6 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
const newRow: Row = {
id: (subFieldState?.id?.value as string) || new ObjectId().toHexString(),
collapsed: false,
isLoading: true,
}

View File

@@ -1,6 +1,9 @@
import type {
ArrayField,
BlocksField,
BuildFormStateArgs,
ClientFieldSchemaMap,
CollapsedPreferences,
Data,
DocumentPreferences,
Field,
@@ -34,6 +37,7 @@ import {
import type { RenderFieldMethod } from './types.js'
import { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js'
import { isRowCollapsed } from './isRowCollapsed.js'
import { iterateFields } from './iterateFields.js'
const ObjectId = (ObjectIdImport.default ||
@@ -334,10 +338,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
acc.rows = []
}
const previousRows = previousFormState?.[path]?.rows || []
// First, check if `previousFormState` has a matching row
const previousRow: Row = previousRows.find((prevRow) => prevRow.id === row.id)
const previousRow: Row = (previousFormState?.[path]?.rows || []).find(
(prevRow) => prevRow.id === row.id,
)
const newRow: Row = {
id: row.id,
@@ -350,24 +354,15 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
acc.rows.push(newRow)
const collapsedRowIDsFromPrefs = preferences?.fields?.[path]?.collapsed
const isCollapsed = isRowCollapsed({
collapsedPrefs: preferences?.fields?.[path]?.collapsed,
field,
previousRow,
row,
})
const collapsed = (() => {
if (previousRow) {
return previousRow.collapsed ?? false
}
// If previousFormState is undefined, check preferences
if (collapsedRowIDsFromPrefs !== undefined) {
return collapsedRowIDsFromPrefs.includes(row.id) // Check if collapsed in preferences
}
// If neither exists, fallback to `field.admin.initCollapsed`
return field.admin.initCollapsed
})()
if (collapsed) {
acc.rows[acc.rows.length - 1].collapsed = collapsed
if (isCollapsed) {
acc.rows[acc.rows.length - 1].collapsed = true
}
return acc
@@ -524,10 +519,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
}),
)
const previousRows = previousFormState?.[path]?.rows || []
// First, check if `previousFormState` has a matching row
const previousRow: Row = previousRows.find((prevRow) => prevRow.id === row.id)
const previousRow: Row = (previousFormState?.[path]?.rows || []).find(
(prevRow) => prevRow.id === row.id,
)
const newRow: Row = {
id: row.id,
@@ -541,15 +536,15 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
acc.rowMetadata.push(newRow)
const collapsedRowIDs = preferences?.fields?.[path]?.collapsed
const isCollapsed = isRowCollapsed({
collapsedPrefs: preferences?.fields?.[path]?.collapsed,
field,
previousRow,
row,
})
const collapsed =
collapsedRowIDs === undefined
? field.admin.initCollapsed
: collapsedRowIDs.includes(row.id)
if (collapsed) {
acc.rowMetadata[acc.rowMetadata.length - 1].collapsed = collapsed
if (isCollapsed) {
acc.rowMetadata[acc.rowMetadata.length - 1].collapsed = true
}
}

View File

@@ -0,0 +1,25 @@
import type { ArrayField, BlocksField, CollapsedPreferences, Row } from 'payload'
export function isRowCollapsed({
collapsedPrefs,
field,
previousRow,
row,
}: {
collapsedPrefs: CollapsedPreferences
field: ArrayField | BlocksField
previousRow: Row | undefined
row: Row
}): boolean {
if (previousRow && 'collapsed' in previousRow) {
return previousRow.collapsed ?? false
}
// If previousFormState is `undefined`, check preferences
if (collapsedPrefs !== undefined) {
return collapsedPrefs.includes(row.id) // Check if collapsed in preferences
}
// If neither exists, fallback to `field.admin.initCollapsed`
return field.admin.initCollapsed
}

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 { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js'
import * as path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { Config, Post } from './payload-types.js'
@@ -494,8 +496,23 @@ test.describe('Bulk Edit', () => {
const { field } = await selectFieldToEdit(page, { fieldLabel: 'Array', fieldID: 'array' })
await wait(500)
await field.locator('button.array-field__add-row').click()
const row = page.locator(`#array-row-0`)
const toggler = row.locator('button.collapsible__toggle')
await expect(toggler).toHaveClass(/collapsible__toggle--collapsed/)
await expect(page.locator(`#field-array__0__optional`)).toBeHidden()
await toggleBlockOrArrayRow({
page,
targetState: 'open',
rowIndex: 0,
fieldName: 'array',
})
await expect(field.locator('#field-array__0__optional')).toBeVisible()
await expect(field.locator('#field-array__0__noRead')).toBeHidden()
await expect(field.locator('#field-array__0__noUpdate')).toBeDisabled()

View File

@@ -169,6 +169,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -287,6 +294,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -3,6 +3,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { assertToastErrors } from 'helpers/assertToastErrors.js'
import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -384,12 +385,43 @@ describe('Array', () => {
await expect(page.locator('#custom-text-field')).toBeVisible()
})
test('should not re-close initCollapsed true array rows on input in create new view', async () => {
await loadCreatePage()
test('should initialize array rows with collapsed state', async () => {
await page.goto(url.create)
await page.locator('#field-collapsedArray >> .array-field__add-row').click()
await page.locator('#field-collapsedArray__0__text').fill('test')
const collapsedArrayRow = page.locator('#collapsedArray-row-0 .collapsible--collapsed')
await expect(collapsedArrayRow).toBeHidden()
const row = page.locator(`#collapsedArray-row-0`)
const toggler = row.locator('button.collapsible__toggle')
await expect(toggler).toHaveClass(/collapsible__toggle--collapsed/)
await expect(page.locator(`#field-collapsedArray__0__text`)).toBeHidden()
})
test('should not collapse array rows on input change', async () => {
await page.goto(url.create)
await page.locator('#field-collapsedArray >> .array-field__add-row').click()
const row = page.locator(`#collapsedArray-row-0`)
const toggler = row.locator('button.collapsible__toggle')
await expect(toggler).toHaveClass(/collapsible__toggle--collapsed/)
await expect(page.locator(`#field-collapsedArray__0__text`)).toBeHidden()
await toggleBlockOrArrayRow({
page,
rowIndex: 0,
fieldName: 'collapsedArray',
targetState: 'open',
})
await page.locator('input#field-collapsedArray__0__text').fill('Hello, world!')
// wait for form state to return, in the future can wire this into watch network requests (if needed)
await wait(1000)
await expect(toggler).toHaveClass(/collapsible__toggle--open/)
await expect(page.locator(`#field-collapsedArray__0__text`)).toBeVisible()
})
describe('sortable arrays', () => {

View File

@@ -5,6 +5,7 @@ import { addBlock } from 'helpers/e2e/addBlock.js'
import { openBlocksDrawer } from 'helpers/e2e/openBlocksDrawer.js'
import { reorderBlocks } from 'helpers/e2e/reorderBlocks.js'
import { scrollEntirePage } from 'helpers/e2e/scrollEntirePage.js'
import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -13,7 +14,7 @@ import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
throttleTest,
// throttleTest,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { assertToastErrors } from '../../../helpers/assertToastErrors.js'
@@ -84,7 +85,7 @@ describe('Block fields', () => {
await addBlock({
page,
fieldName: 'blocks',
blockLabel: 'Content',
blockToSelect: 'Content',
})
// ensure the block was appended to the rows
@@ -186,6 +187,53 @@ describe('Block fields', () => {
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
})
test('should initialize block rows with collapsed state', async () => {
await page.goto(url.create)
await addBlock({
page,
fieldName: 'collapsedByDefaultBlocks',
blockToSelect: 'Localized Content',
})
const row = page.locator(`#collapsedByDefaultBlocks-row-4`)
const toggler = row.locator('button.collapsible__toggle')
await expect(toggler).toHaveClass(/collapsible__toggle--collapsed/)
await expect(page.locator(`#field-collapsedByDefaultBlocks__4__text`)).toBeHidden()
})
test('should not collapse block rows on input change', async () => {
await page.goto(url.create)
await addBlock({
page,
fieldName: 'collapsedByDefaultBlocks',
blockToSelect: 'Localized Content',
})
const row = page.locator(`#collapsedByDefaultBlocks-row-4`)
const toggler = row.locator('button.collapsible__toggle')
await expect(toggler).toHaveClass(/collapsible__toggle--collapsed/)
await expect(page.locator(`#field-collapsedByDefaultBlocks__4__text`)).toBeHidden()
await toggleBlockOrArrayRow({
page,
fieldName: 'collapsedByDefaultBlocks',
targetState: 'open',
rowIndex: 4,
})
await page.locator('input#field-collapsedByDefaultBlocks__4__text').fill('Hello, world!')
// wait for form state to return, in the future can wire this into watch network requests (if needed)
await wait(1000)
await expect(toggler).toHaveClass(/collapsible__toggle--open/)
await expect(page.locator(`#field-collapsedByDefaultBlocks__4__text`)).toBeVisible()
})
test('should use i18n block labels', async () => {
await page.goto(url.create)
await expect(page.locator('#field-i18nBlocks .blocks-field__header')).toContainText('Block en')
@@ -193,7 +241,7 @@ describe('Block fields', () => {
await addBlock({
page,
fieldName: 'i18nBlocks',
blockLabel: 'Text en',
blockToSelect: 'Text en',
})
// ensure the block was appended to the rows
@@ -210,7 +258,7 @@ describe('Block fields', () => {
await addBlock({
page,
fieldName: 'blocks',
blockLabel: 'Content',
blockToSelect: 'Content',
})
await expect(
@@ -226,7 +274,7 @@ describe('Block fields', () => {
await addBlock({
page,
fieldName: 'blocksWithSimilarConfigs',
blockLabel: 'Block A',
blockToSelect: 'Block A',
})
await page
@@ -245,7 +293,7 @@ describe('Block fields', () => {
await addBlock({
page,
fieldName: 'blocksWithSimilarConfigs',
blockLabel: 'Block B',
blockToSelect: 'Block B',
})
await page
@@ -274,7 +322,7 @@ describe('Block fields', () => {
await addBlock({
page,
fieldName: 'blocksWithMinRows',
blockLabel: 'Block With Min Row',
blockToSelect: 'Block With Min Row',
})
const firstRow = page.locator('input[name="blocksWithMinRows.0.blockTitle"]')

View File

@@ -226,6 +226,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -1838,6 +1845,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -88,7 +88,7 @@ test.describe('Form State', () => {
async () => {
await addBlock({
page,
blockLabel: 'Text',
blockToSelect: 'Text',
fieldName: 'blocks',
})
},

View File

@@ -8,21 +8,24 @@ import { openBlocksDrawer } from './openBlocksDrawer.js'
export const addBlock = async ({
page,
fieldName = 'blocks',
blockLabel = 'Block',
blockToSelect = 'Block',
}: {
blockLabel: string
/**
* The name of the block to select from the blocks drawer.
*/
blockToSelect: string
fieldName: string
page: Page
}) => {
const blocksDrawer = await openBlocksDrawer({ page, fieldName })
const blockCard = blocksDrawer.locator('.blocks-drawer__block .thumbnail-card__label', {
hasText: blockLabel,
hasText: blockToSelect,
})
await expect(blockCard).toBeVisible()
await blocksDrawer.getByRole('button', { name: exactText(blockLabel) }).click()
await blocksDrawer.getByRole('button', { name: exactText(blockToSelect) }).click()
// expect to see the block on the page
}

View File

@@ -0,0 +1,60 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
/**
* Works for all collapsible field types, including `collapsible`, `array`, and `blocks`.
* For arrays and blocks, use the `toggleBlockOrArrayRow` helper instead. It will call this function internally.
*/
export const toggleCollapsible = async ({
toggler,
targetState: targetStateFromArgs,
}: {
targetState?: 'collapsed' | 'open'
toggler: Locator
}) => {
const isCollapsedBeforeClick = await toggler.evaluate((el) =>
el.classList.contains('collapsible__toggle--collapsed'),
)
const targetState =
targetStateFromArgs !== undefined
? targetStateFromArgs
: isCollapsedBeforeClick
? 'open'
: 'collapsed'
const requiresToggle =
(isCollapsedBeforeClick && targetState === 'open') ||
(!isCollapsedBeforeClick && targetState === 'collapsed')
if (requiresToggle) {
await toggler.click()
}
if (targetState === 'collapsed') {
await expect(toggler).not.toHaveClass(/collapsible__toggle--open/)
await expect(toggler).toHaveClass(/collapsible__toggle--collapsed/)
} else {
await expect(toggler).toHaveClass(/collapsible__toggle--open/)
await expect(toggler).not.toHaveClass(/collapsible__toggle--collapsed/)
}
}
export const toggleBlockOrArrayRow = async ({
page,
rowIndex,
fieldName,
targetState: targetStateFromArgs,
}: {
fieldName: string
page: Page
rowIndex: number
targetState?: 'collapsed' | 'open'
}) => {
const row = page.locator(`#${fieldName}-row-${rowIndex}`)
const toggler = row.locator('button.collapsible__toggle')
await toggleCollapsible({ toggler, targetState: targetStateFromArgs })
}

View File

@@ -21,15 +21,8 @@
"skipLibCheck": true,
"emitDeclarationOnly": true,
"sourceMap": true,
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"types": [
"node",
"jest"
],
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "jest"],
"incremental": true,
"isolatedModules": true,
"plugins": [
@@ -38,72 +31,36 @@
}
],
"paths": {
"@payload-config": [
"./test/live-preview/config.ts"
],
"@payloadcms/admin-bar": [
"./packages/admin-bar/src"
],
"@payloadcms/live-preview": [
"./packages/live-preview/src"
],
"@payloadcms/live-preview-react": [
"./packages/live-preview-react/src/index.ts"
],
"@payloadcms/live-preview-vue": [
"./packages/live-preview-vue/src/index.ts"
],
"@payloadcms/ui": [
"./packages/ui/src/exports/client/index.ts"
],
"@payloadcms/ui/shared": [
"./packages/ui/src/exports/shared/index.ts"
],
"@payloadcms/ui/rsc": [
"./packages/ui/src/exports/rsc/index.ts"
],
"@payloadcms/ui/scss": [
"./packages/ui/src/scss.scss"
],
"@payloadcms/ui/scss/app.scss": [
"./packages/ui/src/scss/app.scss"
],
"@payloadcms/next/*": [
"./packages/next/src/exports/*.ts"
],
"@payload-config": ["./test/_community/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
"@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"],
"@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"],
"@payloadcms/ui/rsc": ["./packages/ui/src/exports/rsc/index.ts"],
"@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"],
"@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"],
"@payloadcms/next/*": ["./packages/next/src/exports/*.ts"],
"@payloadcms/richtext-lexical/client": [
"./packages/richtext-lexical/src/exports/client/index.ts"
],
"@payloadcms/richtext-lexical/rsc": [
"./packages/richtext-lexical/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-slate/rsc": [
"./packages/richtext-slate/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"],
"@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"],
"@payloadcms/richtext-slate/client": [
"./packages/richtext-slate/src/exports/client/index.ts"
],
"@payloadcms/plugin-seo/client": [
"./packages/plugin-seo/src/exports/client.ts"
],
"@payloadcms/plugin-sentry/client": [
"./packages/plugin-sentry/src/exports/client.ts"
],
"@payloadcms/plugin-stripe/client": [
"./packages/plugin-stripe/src/exports/client.ts"
],
"@payloadcms/plugin-search/client": [
"./packages/plugin-search/src/exports/client.ts"
],
"@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"],
"@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"],
"@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"],
"@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"],
"@payloadcms/plugin-form-builder/client": [
"./packages/plugin-form-builder/src/exports/client.ts"
],
"@payloadcms/plugin-import-export/rsc": [
"./packages/plugin-import-export/src/exports/rsc.ts"
],
"@payloadcms/plugin-multi-tenant/rsc": [
"./packages/plugin-multi-tenant/src/exports/rsc.ts"
],
"@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"],
"@payloadcms/plugin-multi-tenant/utilities": [
"./packages/plugin-multi-tenant/src/exports/utilities.ts"
],
@@ -113,42 +70,25 @@
"@payloadcms/plugin-multi-tenant/client": [
"./packages/plugin-multi-tenant/src/exports/client.ts"
],
"@payloadcms/plugin-multi-tenant": [
"./packages/plugin-multi-tenant/src/index.ts"
],
"@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"],
"@payloadcms/plugin-multi-tenant/translations/languages/all": [
"./packages/plugin-multi-tenant/src/translations/index.ts"
],
"@payloadcms/plugin-multi-tenant/translations/languages/*": [
"./packages/plugin-multi-tenant/src/translations/languages/*.ts"
],
"@payloadcms/next": [
"./packages/next/src/exports/*"
],
"@payloadcms/storage-azure/client": [
"./packages/storage-azure/src/exports/client.ts"
],
"@payloadcms/storage-s3/client": [
"./packages/storage-s3/src/exports/client.ts"
],
"@payloadcms/next": ["./packages/next/src/exports/*"],
"@payloadcms/storage-azure/client": ["./packages/storage-azure/src/exports/client.ts"],
"@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"],
"@payloadcms/storage-vercel-blob/client": [
"./packages/storage-vercel-blob/src/exports/client.ts"
],
"@payloadcms/storage-gcs/client": [
"./packages/storage-gcs/src/exports/client.ts"
],
"@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"],
"@payloadcms/storage-uploadthing/client": [
"./packages/storage-uploadthing/src/exports/client.ts"
]
}
},
"include": [
"${configDir}/src"
],
"exclude": [
"${configDir}/dist",
"${configDir}/build",
"${configDir}/temp",
"**/*.spec.ts"
]
"include": ["${configDir}/src"],
"exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"]
}