Continuation of https://github.com/payloadcms/payload/pull/13501. When merging server form state with `acceptValues: true`, like on submit (not autosave), rows are not deeply merged causing custom row components, like row labels, to disappear. This is because we never attach components to the form state response unless it has re-rendered server-side, so unless we merge these rows with the current state, we lose them. Instead of allowing `acceptValues` to override all local changes to rows, we need to flag any newly added rows with `addedByServer` so they can bypass the merge strategy. Existing rows would continue to be merged as expected, and new rows are simply appended to the end. Discovered here: https://discord.com/channels/967097582721572934/967097582721572937/1408367321797365840 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211115023863814
734 lines
17 KiB
TypeScript
734 lines
17 KiB
TypeScript
import type { FieldState, FormState, Payload, User } from 'payload'
|
|
import type React from 'react'
|
|
|
|
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)
|
|
|
|
const DummyReactComponent: React.ReactNode = {
|
|
// @ts-expect-error - can ignore, needs to satisfy `typeof value.$$typeof === 'symbol'`
|
|
$$typeof: Symbol.for('react.element'),
|
|
type: 'div',
|
|
props: {},
|
|
key: null,
|
|
}
|
|
|
|
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 () => {
|
|
await payload.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: {
|
|
errorPaths: [],
|
|
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: {
|
|
errorPaths: [],
|
|
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)
|
|
})
|
|
|
|
it('should accept all values from the server regardless of local modifications, e.g. `acceptAllValues` on submit', () => {
|
|
const title: FieldState = {
|
|
value: 'Test Post (modified on the client)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
}
|
|
|
|
const currentState: Record<string, FieldState> = {
|
|
title: {
|
|
...title,
|
|
isModified: true, // This is critical, this is what we're testing
|
|
},
|
|
computedTitle: {
|
|
value: 'Test Post (computed on the client)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
array: {
|
|
rows: [
|
|
{
|
|
id: '1',
|
|
customComponents: {
|
|
RowLabel: DummyReactComponent,
|
|
},
|
|
lastRenderedPath: 'array.0.customTextField',
|
|
},
|
|
],
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
'array.0.id': {
|
|
value: '1',
|
|
initialValue: '1',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
'array.0.customTextField': {
|
|
value: 'Test Post (modified on the client)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
}
|
|
|
|
const incomingStateFromServer: Record<string, FieldState> = {
|
|
title: {
|
|
value: 'Test Post (modified on the server)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
computedTitle: {
|
|
value: 'Test Post (computed on the server)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
array: {
|
|
rows: [
|
|
{
|
|
id: '1',
|
|
lastRenderedPath: 'array.0.customTextField',
|
|
// Omit `customComponents` because the server did not re-render this row
|
|
},
|
|
],
|
|
passesCondition: true,
|
|
valid: true,
|
|
},
|
|
'array.0.id': {
|
|
value: '1',
|
|
initialValue: '1',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
'array.0.customTextField': {
|
|
value: 'Test Post (modified on the client)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
}
|
|
|
|
const newState = mergeServerFormState({
|
|
acceptValues: true,
|
|
currentState,
|
|
incomingState: incomingStateFromServer,
|
|
})
|
|
|
|
expect(newState).toStrictEqual({
|
|
...incomingStateFromServer,
|
|
title: {
|
|
...incomingStateFromServer.title,
|
|
isModified: true,
|
|
},
|
|
array: {
|
|
...incomingStateFromServer.array,
|
|
rows: currentState?.array?.rows,
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should not accept values from the server if they have been modified locally since the request was made, e.g. `overrideLocalChanges: false` on autosave', () => {
|
|
const title: FieldState = {
|
|
value: 'Test Post (modified on the client 1)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
}
|
|
|
|
const currentState: Record<string, FieldState> = {
|
|
title: {
|
|
...title,
|
|
isModified: true,
|
|
},
|
|
computedTitle: {
|
|
value: 'Test Post',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
}
|
|
|
|
const incomingStateFromServer: Record<string, FieldState> = {
|
|
title: {
|
|
value: 'Test Post (modified on the server)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
computedTitle: {
|
|
value: 'Test Post (modified on the server)',
|
|
initialValue: 'Test Post',
|
|
valid: true,
|
|
passesCondition: true,
|
|
},
|
|
}
|
|
|
|
const newState = mergeServerFormState({
|
|
acceptValues: { overrideLocalChanges: false },
|
|
currentState,
|
|
incomingState: incomingStateFromServer,
|
|
})
|
|
|
|
expect(newState).toStrictEqual({
|
|
...currentState,
|
|
title: {
|
|
...currentState.title,
|
|
isModified: true,
|
|
},
|
|
computedTitle: incomingStateFromServer.computedTitle, // This field was not modified locally, so should be updated from the server
|
|
})
|
|
})
|
|
})
|