Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,15 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.fieldSchema = field
}

// Short-circuit to prevent hidden fields from recursing and rendering.
// Note: `tab` is excluded bc tab visibility is keyed by `field.id` rather than `path`.
// The tab branch below owns that write and the skip-recursion.
if (passesCondition === false && field.type !== 'tab') {
// Short-circuit hidden fields to prevent recursing and rendering. Two exclusions:
// - `tab`: visibility is keyed by `field.id` (not `path`); the tab branch owns that write.
// - presentational containers (row, collapsible, unnamed group): they hold no value, so
// returning here drops their nested fields' values. They fall through to the
// `fieldHasSubFields` branch, which recurses to preserve child values without rendering.
const isPresentationalWithSubFields =
fieldHasSubFields(field as Field) && !fieldAffectsData(field as Field)

if (passesCondition === false && field.type !== 'tab' && !isPresentationalWithSubFields) {
if (fieldAffectsData(field) && data?.[field.name] !== undefined) {
fieldState.value = data[field.name]
fieldState.initialValue = data[field.name]
Expand Down Expand Up @@ -824,6 +829,13 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
state[path] = {
disableFormData: true,
}

// Presentational containers are hidden client-side via `withCondition`, which reads
// `passesCondition` from their own state entry. Must be set here since these fields
// are excluded from the short-circuit above (which would otherwise carry the flag).
if (passesCondition === false) {
state[path].passesCondition = false
}
}

await iterateFields({
Expand Down
25 changes: 25 additions & 0 deletions test/form-state/collections/Conditions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,30 @@ export const ConditionsCollection: CollectionConfig = {
},
},
},
{
type: 'row',
admin: {
condition: (data) => data?.showField === true,
},
fields: [
{
name: 'conditionalRowField',
type: 'text',
},
],
},
{
type: 'collapsible',
label: 'Conditional Collapsible',
admin: {
condition: (data) => data?.showField === true,
},
fields: [
{
name: 'conditionalCollapsibleField',
type: 'text',
},
],
},
],
}
73 changes: 73 additions & 0 deletions test/form-state/int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,79 @@ describe('Form State', () => {
await payload.delete({ collection: conditionsSlug, id: visibleDoc.id })
})

it('should preserve values of fields nested inside a row hidden by admin.condition', async () => {
const req = await createLocalReq({ user }, payload)

const hiddenDoc = await payload.create({
collection: conditionsSlug,
data: {
showField: false,
conditionalRowField: 'value in db',
},
})

const { state: stateHidden } = await buildFormState({
mockRSCs: true,
id: hiddenDoc.id,
collectionSlug: conditionsSlug,
data: hiddenDoc,
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: true,
req,
schemaPath: conditionsSlug,
})

// The field is nested inside a `row` whose condition is false. Its value exists in
// the DB and must survive in client form state (only its rendering should be skipped).
expect(stateHidden?.conditionalRowField).toBeDefined()
expect(stateHidden?.conditionalRowField?.value).toBe('value in db')

// The row itself must still carry `passesCondition: false` so the client hides it via
// `withCondition` (rather than rendering an empty, visible row).
expect(stateHidden?.['_index-2']?.passesCondition).toBe(false)

await payload.delete({ collection: conditionsSlug, id: hiddenDoc.id })
})

it('should preserve values of fields nested inside a collapsible hidden by admin.condition', async () => {
const req = await createLocalReq({ user }, payload)

const hiddenDoc = await payload.create({
collection: conditionsSlug,
data: {
showField: false,
conditionalCollapsibleField: 'collapsible db value',
},
})

const { state: stateHidden } = await buildFormState({
mockRSCs: true,
id: hiddenDoc.id,
collectionSlug: conditionsSlug,
data: hiddenDoc,
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: true,
req,
schemaPath: conditionsSlug,
})

// Same regression class as `row`: a collapsible is a presentational container, so its
// nested field's value must survive even though the collapsible is hidden.
expect(stateHidden?.conditionalCollapsibleField?.value).toBe('collapsible db value')

await payload.delete({ collection: conditionsSlug, id: hiddenDoc.id })
})

it('should render custom Field component when admin.condition flips from false to true via onChange', async () => {
const req = await createLocalReq({ user }, payload)

Expand Down
4 changes: 4 additions & 0 deletions test/form-state/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ export interface Condition {
id: string;
showField?: boolean | null;
conditionalCustomField?: string | null;
conditionalRowField?: string | null;
conditionalCollapsibleField?: string | null;
updatedAt: string;
createdAt: string;
}
Expand Down Expand Up @@ -378,6 +380,8 @@ export interface AutosavePostsSelect<T extends boolean = true> {
export interface ConditionsSelect<T extends boolean = true> {
showField?: T;
conditionalCustomField?: T;
conditionalRowField?: T;
conditionalCollapsibleField?: T;
updatedAt?: T;
createdAt?: T;
}
Expand Down
Loading