fix(plugin-nested-docs): prevent phantom breadcrumb row (#13628)
When saving a doc and regenerating the breadcrumbs array, a phantom row will append itself to the end of the array on save. This is because of fixes made in #13551 changed the way we merge array and block rows from the server. To fix this we need to ensure that row IDs are consistent across form state invocations, i.e. the hooks that mutate the array rows _cannot_ discard the row IDs. Before: https://github.com/user-attachments/assets/db715801-b4fd-4114-b39b-8d9b37fad979 After: https://github.com/user-attachments/assets/6da63a31-cd5d-43c1-a15e-caddbc540d56 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211175452200168
This commit is contained in:
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ import type { SanitizedCollectionConfig } from 'payload'
|
|||||||
import type { Breadcrumb, GenerateLabel, GenerateURL } from '../types.js'
|
import type { Breadcrumb, GenerateLabel, GenerateURL } from '../types.js'
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
|
/**
|
||||||
|
* Existing breadcrumb, if any, to base the new breadcrumb on.
|
||||||
|
* This ensures that row IDs are maintained across updates, etc.
|
||||||
|
*/
|
||||||
|
breadcrumb?: Breadcrumb
|
||||||
collection: SanitizedCollectionConfig
|
collection: SanitizedCollectionConfig
|
||||||
docs: Record<string, unknown>[]
|
docs: Record<string, unknown>[]
|
||||||
generateLabel?: GenerateLabel
|
generateLabel?: GenerateLabel
|
||||||
generateURL?: GenerateURL
|
generateURL?: GenerateURL
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatBreadcrumb = ({
|
export const formatBreadcrumb = ({
|
||||||
|
breadcrumb,
|
||||||
collection,
|
collection,
|
||||||
docs,
|
docs,
|
||||||
generateLabel,
|
generateLabel,
|
||||||
@@ -32,6 +39,7 @@ export const formatBreadcrumb = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...(breadcrumb || {}),
|
||||||
doc: lastDoc.id as string,
|
doc: lastDoc.id as string,
|
||||||
label,
|
label,
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ export const populateBreadcrumbs = async ({
|
|||||||
req,
|
req,
|
||||||
}: Args): Promise<Data> => {
|
}: Args): Promise<Data> => {
|
||||||
const newData = data
|
const newData = data
|
||||||
|
|
||||||
const currentDocument = {
|
const currentDocument = {
|
||||||
...originalDoc,
|
...originalDoc,
|
||||||
...data,
|
...data,
|
||||||
|
id: originalDoc?.id ?? data?.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParentDocuments: Document[] = await getAllParentDocuments(
|
const allParentDocuments: Document[] = await getAllParentDocuments(
|
||||||
req,
|
req,
|
||||||
{
|
{
|
||||||
@@ -41,14 +44,11 @@ export const populateBreadcrumbs = async ({
|
|||||||
currentDocument,
|
currentDocument,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (originalDoc?.id) {
|
|
||||||
currentDocument.id = originalDoc?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
allParentDocuments.push(currentDocument)
|
allParentDocuments.push(currentDocument)
|
||||||
|
|
||||||
const breadcrumbs = allParentDocuments.map((_, i) =>
|
const breadcrumbs = allParentDocuments.map((_, i) =>
|
||||||
formatBreadcrumb({
|
formatBreadcrumb({
|
||||||
|
breadcrumb: currentDocument[breadcrumbsFieldName]?.[i],
|
||||||
collection,
|
collection,
|
||||||
docs: allParentDocuments.slice(0, i + 1),
|
docs: allParentDocuments.slice(0, i + 1),
|
||||||
generateLabel,
|
generateLabel,
|
||||||
|
|||||||
@@ -76,24 +76,10 @@ export const PostsCollection: CollectionConfig = {
|
|||||||
name: 'array',
|
name: 'array',
|
||||||
type: 'array',
|
type: 'array',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'If there is no value, a default row will be added by a beforeChange hook',
|
|
||||||
components: {
|
components: {
|
||||||
RowLabel: './collections/Posts/ArrayRowLabel.js#ArrayRowLabel',
|
RowLabel: './collections/Posts/ArrayRowLabel.js#ArrayRowLabel',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hooks: {
|
|
||||||
beforeChange: [
|
|
||||||
({ value }) =>
|
|
||||||
!value?.length
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
defaultTextField: 'This is a computed value.',
|
|
||||||
customTextField: 'This is a computed value.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: value,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'customTextField',
|
name: 'customTextField',
|
||||||
@@ -111,5 +97,31 @@ export const PostsCollection: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'computedArray',
|
||||||
|
type: 'array',
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'If there is no value, a default row will be added by a beforeChange hook. Otherwise, modifies the rows on save.',
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [
|
||||||
|
({ value }) =>
|
||||||
|
!value?.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: 'This is a computed value.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: value,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'text',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,22 +312,18 @@ test.describe('Form State', () => {
|
|||||||
|
|
||||||
// Now test array rows, as their merge logic is different
|
// Now test array rows, as their merge logic is different
|
||||||
|
|
||||||
await page.locator('#field-array #array-row-0').isVisible()
|
await page.locator('#field-computedArray #computedArray-row-0').isVisible()
|
||||||
|
|
||||||
await removeArrayRow(page, { fieldName: 'array' })
|
await removeArrayRow(page, { fieldName: 'computedArray' })
|
||||||
|
|
||||||
await page.locator('#field-array .array-row-0').isHidden()
|
await page.locator('#field-computedArray #computedArray-row-0').isHidden()
|
||||||
|
|
||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
|
|
||||||
await expect(page.locator('#field-array #array-row-0')).toBeVisible()
|
await expect(page.locator('#field-computedArray #computedArray-row-0')).toBeVisible()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('#field-array #array-row-0 #field-array__0__customTextField'),
|
page.locator('#field-computedArray #computedArray-row-0 #field-computedArray__0__text'),
|
||||||
).toHaveValue('This is a computed value.')
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.locator('#field-array #array-row-0 #field-array__0__defaultTextField'),
|
|
||||||
).toHaveValue('This is a computed value.')
|
).toHaveValue('This is a computed value.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -144,9 +144,6 @@ export interface Post {
|
|||||||
}
|
}
|
||||||
)[]
|
)[]
|
||||||
| null;
|
| null;
|
||||||
/**
|
|
||||||
* If there is no value, a default row will be added by a beforeChange hook
|
|
||||||
*/
|
|
||||||
array?:
|
array?:
|
||||||
| {
|
| {
|
||||||
customTextField?: string | null;
|
customTextField?: string | null;
|
||||||
@@ -154,6 +151,15 @@ export interface Post {
|
|||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
|
/**
|
||||||
|
* If there is no value, a default row will be added by a beforeChange hook. Otherwise, modifies the rows on save.
|
||||||
|
*/
|
||||||
|
computedArray?:
|
||||||
|
| {
|
||||||
|
text?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -288,6 +294,12 @@ export interface PostsSelect<T extends boolean = true> {
|
|||||||
defaultTextField?: T;
|
defaultTextField?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
|
computedArray?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
text?: T;
|
||||||
|
id?: T;
|
||||||
|
};
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ export interface User {
|
|||||||
hash?: string | null;
|
hash?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
|
sessions?:
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -295,6 +302,13 @@ export interface UsersSelect<T extends boolean = true> {
|
|||||||
hash?: T;
|
hash?: T;
|
||||||
loginAttempts?: T;
|
loginAttempts?: T;
|
||||||
lockUntil?: T;
|
lockUntil?: T;
|
||||||
|
sessions?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
id?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
expiresAt?: T;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
|||||||
Reference in New Issue
Block a user