Files
payloadcms/test/form-state/int.spec.ts
Philipp Schneider a62cdc89d8 fix(ui): blockType ignored when merging server form state (#12207)
In this case, the `blockType` property is created on the server, but -
prior to this fix - was discarded on the client in
[`fieldReducer.ts`](https://github.com/payloadcms/payload/blob/main/packages/ui/src/forms/Form/fieldReducer.ts#L186-L198)
via
[`mergerServerFormState.ts`](b9832f40e4/packages/ui/src/forms/Form/mergeServerFormState.ts (L29-L31)),
because the field's path neither existed in the client's form state, nor
was it marked as `addedByServer`.

This caused later calls to POST requests to form state to send without
the `blockType` key for block rows, which in turn caused
`addFieldStatePromise.ts` to throw the following error:

```
Block with type "undefined" was found in block data, but no block with that type is defined in the config for field with schema path ${schemaPath}.
```

This prevented the client side form state update from completing, and if
the form state was saved, broke the document.

This is a follow-up to #12131, which treated the symptom, but not the
cause. The original issue seems to have been introduced in
https://github.com/payloadcms/payload/releases/tag/v3.34.0. It's unclear
to me whether this issue is connected to block E2E tests having been
disabled in the same release in
https://github.com/payloadcms/payload/pull/11988.

## How to reproduce

### Collection configuration

```ts
const RICH_TEXT_BLOCK_TYPE = 'richTextBlockType'

const RichTextBlock: Block = {
  slug: RICH_TEXT_BLOCK_TYPE,
  interfaceName: 'RichTextBlock',
  fields: [
    {
      name: 'richTextBlockField',
      label: 'Rich Text Field in Block Field',
      type: 'richText',
      editor: lexicalEditor({}),
      required: true,
    },
  ],
}

const MyCollection: CollectionConfig = {
  slug: 'my-collection-slug,
  fields: [
    {
      name: 'arrayField',
      label: 'Array Field',
      type: 'array',
      fields: [
        {
          name: 'blockField',
          type: 'blocks',
          blocks: [RichTextBlock],
          required: true,
        },
      ],
    },
  ]
}

export default MyCollection
```

### Steps

- Press "Add Array Field"
   -->  1st block with rich text is added
- Press "Add Array Field" a 2nd time

### Result
- 🛑 2nd block is indefinitely in loading state (side-note: the form UI
should preferably explicitly indicate the error).
- 🛑 If saving the document, it is corrupted and will only show a blank
page (also not indicating any error).

Client side:

<img width="1268" alt="Untitled"
src="https://github.com/user-attachments/assets/4b32fdeb-af76-41e2-9181-d2dbd686618a"
/>

API error:

<img width="1272" alt="image"
src="https://github.com/user-attachments/assets/35dc65f7-88ac-4397-b8d4-353bcf6a4bfd"
/>

Client side, when saving and re-opening document (API error of `GET
/admin/collections/${myCollection}/${documentId}` is the same (arguably
the HTTP response status code shouldn't be `200`)):

<img width="1281" alt="image"
src="https://github.com/user-attachments/assets/2e916eb5-6f10-4e82-9b84-1dc41db21d47"
/>

### Result after fix
- `blockType` is sent from the client to the server.
-  2nd block with rich text is added.
-  Document does not break when saving & re-opening.

<img width="1277" alt="Untitled"
src="https://github.com/user-attachments/assets/84d0c88b-64b2-48c4-864d-610d524ac8fc"
/>

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-05-02 10:18:11 -04:00

569 lines
13 KiB
TypeScript

import type { FormState, Payload, User } from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import path from 'path'
import { createLocalReq } from 'payload'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { postsSlug } from './collections/Posts/index.js'
// eslint-disable-next-line payload/no-relative-monorepo-imports
import { mergeServerFormState } from '../../packages/ui/src/forms/Form/mergeServerFormState.js'
let payload: Payload
let restClient: NextRESTClient
let user: User
const { email, password } = devUser
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Form State', () => {
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname, undefined, true))
const data = await restClient
.POST('/users/login', {
body: JSON.stringify({
email,
password,
}),
})
.then((res) => res.json())
user = data.user
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
it('should build entire form state', async () => {
const req = await createLocalReq({ user }, payload)
const postData = await payload.create({
collection: postsSlug,
data: {
title: 'Test Post',
},
})
const { state } = await buildFormState({
mockRSCs: true,
id: postData.id,
collectionSlug: postsSlug,
data: postData,
docPermissions: {
create: true,
delete: true,
fields: true,
read: true,
readVersions: true,
update: true,
},
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
expect(state).toMatchObject({
title: {
value: postData.title,
initialValue: postData.title,
},
updatedAt: {
value: postData.updatedAt,
initialValue: postData.updatedAt,
},
createdAt: {
value: postData.createdAt,
initialValue: postData.createdAt,
},
renderTracker: {},
validateUsingEvent: {},
blocks: {
initialValue: 0,
rows: [],
value: 0,
},
})
})
it('should use `select` to build partial form state with only specified fields', async () => {
const req = await createLocalReq({ user }, payload)
const postData = await payload.create({
collection: postsSlug,
data: {
title: 'Test Post',
},
})
const { state } = await buildFormState({
mockRSCs: true,
id: postData.id,
collectionSlug: postsSlug,
data: postData,
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
select: {
title: true,
},
})
expect(state).toStrictEqual({
title: {
value: postData.title,
initialValue: postData.title,
lastRenderedPath: 'title',
addedByServer: true,
},
})
})
it('should not render custom components when `lastRenderedPath` exists', async () => {
const req = await createLocalReq({ user }, payload)
const { state: stateWithRow } = await buildFormState({
mockRSCs: true,
collectionSlug: postsSlug,
formState: {
array: {
rows: [
{
id: '123',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
// Ensure that row 1 _DOES_ return with rendered components
expect(stateWithRow?.['array.0.customTextField']?.lastRenderedPath).toStrictEqual(
'array.0.customTextField',
)
expect(stateWithRow?.['array.0.customTextField']?.customComponents?.Field).toBeDefined()
const { state: stateWithTitle } = await buildFormState({
mockRSCs: true,
collectionSlug: postsSlug,
formState: {
array: {
rows: [
{
id: '123',
},
{
id: '456',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
'array.0.customTextField': {
lastRenderedPath: 'array.0.customTextField',
},
'array.1.id': {
value: '456',
initialValue: '456',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
schemaPath: postsSlug,
req,
})
// Ensure that row 1 _DOES NOT_ return with rendered components
expect(stateWithTitle?.['array.0.customTextField']).toHaveProperty('lastRenderedPath')
expect(stateWithTitle?.['array.0.customTextField']).not.toHaveProperty('customComponents')
// Ensure that row 2 _DOES_ return with rendered components
expect(stateWithTitle?.['array.1.customTextField']).toHaveProperty('lastRenderedPath')
expect(stateWithTitle?.['array.1.customTextField']).toHaveProperty('customComponents')
expect(stateWithTitle?.['array.1.customTextField']?.customComponents?.Field).toBeDefined()
})
it('should add `addedByServer` flag to fields that originate on the server', async () => {
const req = await createLocalReq({ user }, payload)
const postData = await payload.create({
collection: postsSlug,
data: {
title: 'Test Post',
blocks: [
{
blockType: 'text',
text: 'Test block',
},
],
},
})
const { state } = await buildFormState({
mockRSCs: true,
id: postData.id,
collectionSlug: postsSlug,
data: postData,
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
expect(state.title?.addedByServer).toBe(true)
expect(state['blocks.0.blockType']?.addedByServer).toBe(true)
// Ensure that `addedByServer` is removed after being received by the client
const newState = mergeServerFormState({
currentState: state,
incomingState: state,
})
expect(newState.title?.addedByServer).toBeUndefined()
})
it('should not omit value and initialValue from fields added by the server', () => {
const currentState: FormState = {
array: {
rows: [
{
id: '1',
},
],
},
}
const serverState: FormState = {
array: {
rows: [
{
id: '1',
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
},
'array.0.customTextField': {
value: 'Test',
initialValue: 'Test',
addedByServer: true,
},
}
const newState = mergeServerFormState({
currentState,
incomingState: serverState,
})
expect(newState['array.0.customTextField']).toStrictEqual({
passesCondition: true,
valid: true,
value: 'Test',
initialValue: 'Test',
})
})
it('should merge array rows without losing rows added to local state', () => {
const currentState: FormState = {
array: {
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
},
{
id: '2',
isLoading: true,
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
},
'array.1.id': {
value: '2',
initialValue: '2',
},
}
const serverState: FormState = {
array: {
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
},
'array.0.customTextField': {
value: 'Test',
initialValue: 'Test',
addedByServer: true,
},
}
const newState = mergeServerFormState({
currentState,
incomingState: serverState,
})
// Row 2 should still exist
expect(newState).toStrictEqual({
array: {
passesCondition: true,
valid: true,
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
},
{
id: '2',
isLoading: true,
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
passesCondition: true,
valid: true,
},
'array.0.customTextField': {
value: 'Test',
initialValue: 'Test',
passesCondition: true,
valid: true,
},
'array.1.id': {
value: '2',
initialValue: '2',
},
})
})
it('should merge array rows without bringing back rows deleted from local state', () => {
const currentState: FormState = {
array: {
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
},
}
const serverState: FormState = {
array: {
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
},
{
id: '2',
lastRenderedPath: 'array.1.customTextField',
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
},
'array.0.customTextField': {
value: 'Test',
initialValue: 'Test',
addedByServer: true,
},
'array.1.id': {
value: '2',
initialValue: '2',
},
'array.1.customTextField': {
value: 'Test',
initialValue: 'Test',
},
}
const newState = mergeServerFormState({
currentState,
incomingState: serverState,
})
// Row 2 should not exist
expect(newState).toStrictEqual({
array: {
passesCondition: true,
valid: true,
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
passesCondition: true,
valid: true,
},
'array.0.customTextField': {
value: 'Test',
initialValue: 'Test',
passesCondition: true,
valid: true,
},
})
})
it('should merge new fields returned from the server that do not yet exist in local state', () => {
const currentState: FormState = {
array: {
rows: [
{
id: '1',
isLoading: true,
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
},
}
const serverState: FormState = {
array: {
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
isLoading: false,
},
],
},
'array.0.id': {
value: '1',
initialValue: '1',
},
'array.0.customTextField': {
value: 'Test',
initialValue: 'Test',
addedByServer: true,
},
}
const newState = mergeServerFormState({
currentState,
incomingState: serverState,
})
expect(newState).toStrictEqual({
array: {
passesCondition: true,
valid: true,
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
isLoading: false,
},
],
},
'array.0.id': {
passesCondition: true,
valid: true,
value: '1',
initialValue: '1',
},
'array.0.customTextField': {
passesCondition: true,
valid: true,
value: 'Test',
initialValue: 'Test',
},
})
})
it('should return the same object reference when only modifying a value', () => {
const currentState = {
title: {
value: 'Test Post',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const newState = mergeServerFormState({
currentState,
incomingState: {
title: {
value: 'Test Post (modified)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
},
})
expect(newState === currentState).toBe(true)
})
})