fix(ui): significantly more predictable autosave form state (#13460)

This commit is contained in:
jacobsfletch
2025-08-14 19:36:02 -04:00
committed by GitHub
parent 46699ec314
commit 0b60bf2eff
20 changed files with 278 additions and 77 deletions

View File

@@ -207,7 +207,7 @@ Everything mentioned above applies to local development as well, but there are a
### Enable Turbopack ### Enable Turbopack
<Banner type="warning"> <Banner type="warning">
**Note:** In the future this will be the default. Use as your own risk. **Note:** In the future this will be the default. Use at your own risk.
</Banner> </Banner>
Add `--turbo` to your dev script to significantly speed up your local development server start time. Add `--turbo` to your dev script to significantly speed up your local development server start time.

View File

@@ -56,6 +56,12 @@ export type FieldState = {
fieldSchema?: Field fieldSchema?: Field
filterOptions?: FilterOptionsResult filterOptions?: FilterOptionsResult
initialValue?: unknown initialValue?: unknown
/**
* @experimental - Note: this property is experimental and may change in the future. Use at your own discretion.
* Every time a field is changed locally, this flag is set to true. Prevents form state from server from overwriting local changes.
* After merging server form state, this flag is reset.
*/
isModified?: boolean
/** /**
* The path of the field when its custom components were last rendered. * The path of the field when its custom components were last rendered.
* This is used to denote if a field has been rendered, and if so, * This is used to denote if a field has been rendered, and if so,
@@ -114,8 +120,10 @@ export type BuildFormStateArgs = {
mockRSCs?: boolean mockRSCs?: boolean
operation?: 'create' | 'update' operation?: 'create' | 'update'
readOnly?: boolean readOnly?: boolean
/* /**
If true, will render field components within their state object * If true, will render field components within their state object.
* Performance optimization: Setting to `false` ensures that only fields that have changed paths will re-render, e.g. new array rows, etc.
* For example, you only need to render ALL fields on initial render, not on every onChange.
*/ */
renderAllFields?: boolean renderAllFields?: boolean
req: PayloadRequest req: PayloadRequest

View File

@@ -149,7 +149,9 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
if (!skipSubmission && modifiedRef.current && url) { if (!skipSubmission && modifiedRef.current && url) {
const result = await submit({ const result = await submit<{
incrementVersionCount: boolean
}>({
acceptValues: { acceptValues: {
overrideLocalChanges: false, overrideLocalChanges: false,
}, },

View File

@@ -21,7 +21,7 @@ export type DocumentDrawerContextProps = {
readonly onSave?: (args: { readonly onSave?: (args: {
collectionConfig?: ClientCollectionConfig collectionConfig?: ClientCollectionConfig
/** /**
* @experimental - Note: this property is experimental and may change in the future. Use as your own discretion. * @experimental - Note: this property is experimental and may change in the future. Use at your own discretion.
* If you want to pass additional data to the onSuccess callback, you can use this context object. * If you want to pass additional data to the onSuccess callback, you can use this context object.
*/ */
context?: Record<string, unknown> context?: Record<string, unknown>

View File

@@ -189,12 +189,11 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
} }
case 'MERGE_SERVER_STATE': { case 'MERGE_SERVER_STATE': {
const { acceptValues, formStateAtTimeOfRequest, prevStateRef, serverState } = action const { acceptValues, prevStateRef, serverState } = action
const newState = mergeServerFormState({ const newState = mergeServerFormState({
acceptValues, acceptValues,
currentState: state || {}, currentState: state || {},
formStateAtTimeOfRequest,
incomingState: serverState, incomingState: serverState,
}) })
@@ -385,6 +384,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
return { return {
...field, ...field,
[key]: value, [key]: value,
...(key === 'value' ? { isModified: true } : {}),
} }
} }
@@ -398,6 +398,15 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[action.path]: newField, [action.path]: newField,
} }
// reset `isModified` in all other fields
if ('value' in action) {
for (const [path, field] of Object.entries(newState)) {
if (path !== action.path && 'isModified' in field) {
delete newState[path].isModified
}
}
}
return newState return newState
} }

View File

@@ -264,23 +264,21 @@ export const Form: React.FC<FormProps> = (props) => {
await wait(100) await wait(100)
} }
/**
* Take copies of the current form state and data here. This will ensure it is consistent.
* For example, it is possible for the form state ref to change in the background while this submit function is running.
* TODO: can we send the `formStateCopy` through `reduceFieldsToValues` to even greater consistency? Doing this currently breaks uploads.
*/
const formStateCopy = deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields)
const data = reduceFieldsToValues(contextRef.current.fields, true) const data = reduceFieldsToValues(contextRef.current.fields, true)
// Execute server side validations // Execute server side validations
if (Array.isArray(beforeSubmit)) { if (Array.isArray(beforeSubmit)) {
const serializableFormState = deepCopyObjectSimpleWithoutReactComponents(
contextRef.current.fields,
)
let revalidatedFormState: FormState let revalidatedFormState: FormState
await beforeSubmit.reduce(async (priorOnChange, beforeSubmitFn) => { await beforeSubmit.reduce(async (priorOnChange, beforeSubmitFn) => {
await priorOnChange await priorOnChange
const result = await beforeSubmitFn({ const result = await beforeSubmitFn({
formState: formStateCopy, formState: serializableFormState,
}) })
revalidatedFormState = result revalidatedFormState = result
@@ -328,7 +326,7 @@ export const Form: React.FC<FormProps> = (props) => {
data[key] = value data[key] = value
} }
onSubmit(formStateCopy, data) onSubmit(contextRef.current.fields, data)
} }
if (!hasFormSubmitAction) { if (!hasFormSubmitAction) {
@@ -379,13 +377,14 @@ export const Form: React.FC<FormProps> = (props) => {
if (res.status < 400) { if (res.status < 400) {
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
const newFormState = await onSuccess(json, context) const newFormState = await onSuccess(json, {
context,
})
if (newFormState) { if (newFormState) {
dispatchFields({ dispatchFields({
type: 'MERGE_SERVER_STATE', type: 'MERGE_SERVER_STATE',
acceptValues, acceptValues,
formStateAtTimeOfRequest: formStateCopy,
prevStateRef: prevFormState, prevStateRef: prevFormState,
serverState: newFormState, serverState: newFormState,
}) })

View File

@@ -21,7 +21,6 @@ export type AcceptValues =
type Args = { type Args = {
acceptValues?: AcceptValues acceptValues?: AcceptValues
currentState?: FormState currentState?: FormState
formStateAtTimeOfRequest?: FormState
incomingState: FormState incomingState: FormState
} }
@@ -37,7 +36,6 @@ type Args = {
export const mergeServerFormState = ({ export const mergeServerFormState = ({
acceptValues, acceptValues,
currentState = {}, currentState = {},
formStateAtTimeOfRequest,
incomingState, incomingState,
}: Args): FormState => { }: Args): FormState => {
const newState = { ...currentState } const newState = { ...currentState }
@@ -49,8 +47,9 @@ export const mergeServerFormState = ({
/** /**
* If it's a new field added by the server, always accept the value. * If it's a new field added by the server, always accept the value.
* Otherwise, only accept the values if explicitly requested, e.g. on submit. * Otherwise:
* Can also control this granularly by only accepting unmodified values, e.g. for autosave. * a. accept all values when explicitly requested, e.g. on submit
* b. only accept values for unmodified fields, e.g. on autosave
*/ */
if ( if (
!incomingField.addedByServer && !incomingField.addedByServer &&
@@ -59,7 +58,7 @@ export const mergeServerFormState = ({
(typeof acceptValues === 'object' && (typeof acceptValues === 'object' &&
acceptValues !== null && acceptValues !== null &&
acceptValues?.overrideLocalChanges === false && acceptValues?.overrideLocalChanges === false &&
currentState[path]?.value !== formStateAtTimeOfRequest?.[path]?.value)) currentState[path].isModified))
) { ) {
delete incomingField.value delete incomingField.value
delete incomingField.initialValue delete incomingField.initialValue

View File

@@ -54,7 +54,15 @@ export type FormProps = {
log?: boolean log?: boolean
onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise<FormState>)[] onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise<FormState>)[]
onSubmit?: (fields: FormState, data: Data) => void onSubmit?: (fields: FormState, data: Data) => void
onSuccess?: (json: unknown, context?: Record<string, unknown>) => Promise<FormState | void> | void onSuccess?: (
json: unknown,
options?: {
/**
* Arbitrary context passed to the onSuccess callback.
*/
context?: Record<string, unknown>
},
) => Promise<FormState | void> | void
redirect?: string redirect?: string
submitted?: boolean submitted?: boolean
uuid?: string uuid?: string
@@ -70,14 +78,14 @@ export type FormProps = {
} }
) )
export type SubmitOptions = { export type SubmitOptions<T = Record<string, unknown>> = {
acceptValues?: AcceptValues acceptValues?: AcceptValues
action?: string action?: string
/** /**
* @experimental - Note: this property is experimental and may change in the future. Use as your own discretion. * @experimental - Note: this property is experimental and may change in the future. Use at your own discretion.
* If you want to pass additional data to the onSuccess callback, you can use this context object. * If you want to pass additional data to the onSuccess callback, you can use this context object.
*/ */
context?: Record<string, unknown> context?: T
/** /**
* When true, will disable the form while it is processing. * When true, will disable the form while it is processing.
* @default true * @default true
@@ -99,11 +107,11 @@ export type SubmitOptions = {
export type DispatchFields = React.Dispatch<any> export type DispatchFields = React.Dispatch<any>
export type Submit = ( export type Submit = <T extends Record<string, unknown>>(
options?: SubmitOptions, options?: SubmitOptions<T>,
e?: React.FormEvent<HTMLFormElement>, e?: React.FormEvent<HTMLFormElement>,
) => Promise</** ) => Promise</**
* @experimental - Note: the `{ res: ... }` return type is experimental and may change in the future. Use as your own discretion. * @experimental - Note: the `{ res: ... }` return type is experimental and may change in the future. Use at your own discretion.
* Returns the form state and the response from the server. * Returns the form state and the response from the server.
*/ */
{ formState?: FormState; res: Response } | void> { formState?: FormState; res: Response } | void>
@@ -185,7 +193,6 @@ export type ADD_ROW = {
export type MERGE_SERVER_STATE = { export type MERGE_SERVER_STATE = {
acceptValues?: AcceptValues acceptValues?: AcceptValues
formStateAtTimeOfRequest?: FormState
prevStateRef: React.RefObject<FormState> prevStateRef: React.RefObject<FormState>
serverState: FormState serverState: FormState
type: 'MERGE_SERVER_STATE' type: 'MERGE_SERVER_STATE'

View File

@@ -5,7 +5,6 @@ import type {
DocumentPreferences, DocumentPreferences,
Field, Field,
FieldSchemaMap, FieldSchemaMap,
FieldState,
FormState, FormState,
FormStateWithoutComponents, FormStateWithoutComponents,
PayloadRequest, PayloadRequest,
@@ -105,6 +104,7 @@ export const fieldSchemasToFormState = async ({
skipValidation, skipValidation,
}: Args): Promise<FormState> => { }: Args): Promise<FormState> => {
if (!clientFieldSchemaMap && renderFieldFn) { if (!clientFieldSchemaMap && renderFieldFn) {
// eslint-disable-next-line no-console
console.warn( console.warn(
'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance', 'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance',
) )

View File

@@ -5,7 +5,6 @@ import type {
DocumentPreferences, DocumentPreferences,
Field as FieldSchema, Field as FieldSchema,
FieldSchemaMap, FieldSchemaMap,
FieldState,
FormState, FormState,
FormStateWithoutComponents, FormStateWithoutComponents,
PayloadRequest, PayloadRequest,

View File

@@ -182,11 +182,13 @@ export const buildFormState = async (
} }
let documentData = undefined let documentData = undefined
if (documentFormState) { if (documentFormState) {
documentData = reduceFieldsToValues(documentFormState, true) documentData = reduceFieldsToValues(documentFormState, true)
} }
let blockData = initialBlockData let blockData = initialBlockData
if (initialBlockFormState) { if (initialBlockFormState) {
blockData = reduceFieldsToValues(initialBlockFormState, true) blockData = reduceFieldsToValues(initialBlockFormState, true)
} }

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-compiler/react-compiler -- TODO: fix */ /* eslint-disable react-compiler/react-compiler -- TODO: fix */
'use client' 'use client'
import type { ClientUser, DocumentViewClientProps, FormState } from 'payload' import type { ClientUser, DocumentViewClientProps } from 'payload'
import { useRouter, useSearchParams } from 'next/navigation.js' import { useRouter, useSearchParams } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared' import { formatAdminURL } from 'payload/shared'
@@ -256,10 +256,13 @@ export function DefaultEditView({
user?.id, user?.id,
]) ])
const onSave = useCallback( const onSave = useCallback<FormProps['onSuccess']>(
async (json, context?: Record<string, unknown>): Promise<FormState> => { async (json, options) => {
const { context } = options || {}
const controller = handleAbortRef(abortOnSaveRef) const controller = handleAbortRef(abortOnSaveRef)
// @ts-expect-error can ignore
const document = json?.doc || json?.result const document = json?.doc || json?.result
const updatedAt = document?.updatedAt || new Date().toISOString() const updatedAt = document?.updatedAt || new Date().toISOString()
@@ -290,9 +293,10 @@ export function DefaultEditView({
const operation = id ? 'update' : 'create' const operation = id ? 'update' : 'create'
void onSaveFromContext({ void onSaveFromContext({
...json, ...(json as Record<string, unknown>),
context, context,
operation, operation,
// @ts-expect-error todo: this is not right, should be under `doc`?
updatedAt: updatedAt:
operation === 'update' operation === 'update'
? new Date().toISOString() ? new Date().toISOString()
@@ -397,13 +401,11 @@ export function DefaultEditView({
formState: prevFormState, formState: prevFormState,
globalSlug, globalSlug,
operation, operation,
skipValidation: !submitted,
// Performance optimization: Setting it to false ensure that only fields that have explicit requireRender set in the form state will be rendered (e.g. new array rows).
// We only want to render ALL fields on initial render, not in onChange.
renderAllFields: false, renderAllFields: false,
returnLockStatus: isLockingEnabled, returnLockStatus: isLockingEnabled,
schemaPath: schemaPathSegments.join('.'), schemaPath: schemaPathSegments.join('.'),
signal: controller.signal, signal: controller.signal,
skipValidation: !submitted,
updateLastEdited, updateLastEdited,
}) })

View File

@@ -0,0 +1,31 @@
import type { CollectionConfig } from 'payload'
export const autosavePostsSlug = 'autosave-posts'
export const AutosavePostsCollection: CollectionConfig = {
slug: autosavePostsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'computedTitle',
type: 'text',
hooks: {
beforeChange: [({ data }) => data?.title],
},
label: 'Computed Title',
},
],
versions: {
drafts: {
autosave: {
interval: 100,
},
},
},
}

View File

@@ -12,6 +12,14 @@ export const PostsCollection: CollectionConfig = {
name: 'title', name: 'title',
type: 'text', type: 'text',
}, },
{
name: 'computedTitle',
type: 'text',
hooks: {
beforeChange: [({ data }) => data?.title],
},
label: 'Computed Title',
},
{ {
name: 'renderTracker', name: 'renderTracker',
type: 'text', type: 'text',

View File

@@ -3,13 +3,14 @@ import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js' import { devUser } from '../credentials.js'
import { AutosavePostsCollection } from './collections/Autosave/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js' import { PostsCollection, postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
export default buildConfigWithDefaults({ export default buildConfigWithDefaults({
collections: [PostsCollection], collections: [PostsCollection, AutosavePostsCollection],
admin: { admin: {
importMap: { importMap: {
baseDir: path.resolve(dirname), baseDir: path.resolve(dirname),

View File

@@ -7,6 +7,7 @@ import { addBlock } from 'helpers/e2e/addBlock.js'
import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible.js' import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible.js'
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js' import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js' import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js'
import { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js'
import * as path from 'path' import * as path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -21,6 +22,7 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { autosavePostsSlug } from './collections/Autosave/index.js'
import { postsSlug } from './collections/Posts/index.js' import { postsSlug } from './collections/Posts/index.js'
const { describe, beforeEach, afterEach } = test const { describe, beforeEach, afterEach } = test
@@ -36,11 +38,13 @@ let serverURL: string
test.describe('Form State', () => { test.describe('Form State', () => {
let page: Page let page: Page
let postsUrl: AdminUrlUtil let postsUrl: AdminUrlUtil
let autosavePostsUrl: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => { test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, postsSlug) postsUrl = new AdminUrlUtil(serverURL, postsSlug)
autosavePostsUrl = new AdminUrlUtil(serverURL, autosavePostsSlug)
context = await browser.newContext() context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -296,6 +300,59 @@ test.describe('Form State', () => {
await cdpSession.detach() await cdpSession.detach()
}) })
test('should render computed values after save', async () => {
await page.goto(postsUrl.create)
const titleField = page.locator('#field-title')
const computedTitleField = page.locator('#field-computedTitle')
await titleField.fill('Test Title')
await expect(computedTitleField).toHaveValue('')
await saveDocAndAssert(page)
await expect(computedTitleField).toHaveValue('Test Title')
})
test('autosave - should render computed values after autosave', async () => {
await page.goto(autosavePostsUrl.create)
const titleField = page.locator('#field-title')
const computedTitleField = page.locator('#field-computedTitle')
await titleField.fill('Test Title')
await waitForAutoSaveToRunAndComplete(page)
await expect(computedTitleField).toHaveValue('Test Title')
})
test('autosave - should not overwrite computed values that are being actively edited', async () => {
await page.goto(autosavePostsUrl.create)
const titleField = page.locator('#field-title')
const computedTitleField = page.locator('#field-computedTitle')
await titleField.fill('Test Title')
await expect(computedTitleField).toHaveValue('Test Title')
// Put cursor at end of text
await computedTitleField.evaluate((el: HTMLInputElement) => {
el.focus()
el.setSelectionRange(el.value.length, el.value.length)
})
await computedTitleField.pressSequentially(' - Edited', { delay: 100 })
await waitForAutoSaveToRunAndComplete(page)
await expect(computedTitleField).toHaveValue('Test Title - Edited')
// but then when editing another field, the computed field should update
await titleField.fill('Test Title 2')
await waitForAutoSaveToRunAndComplete(page)
await expect(computedTitleField).toHaveValue('Test Title 2')
})
describe('Throttled tests', () => { describe('Throttled tests', () => {
let cdpSession: CDPSession let cdpSession: CDPSession

View File

@@ -1,4 +1,4 @@
import type { FormState, Payload, User } from 'payload' import type { FieldState, FormState, Payload, User } from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import path from 'path' import path from 'path'
@@ -567,12 +567,17 @@ describe('Form State', () => {
}) })
it('should accept all values from the server regardless of local modifications, e.g. on submit', () => { it('should accept all values from the server regardless of local modifications, e.g. on submit', () => {
const currentState = { const title: FieldState = {
title: {
value: 'Test Post (modified on the client)', value: 'Test Post (modified on the client)',
initialValue: 'Test Post', initialValue: 'Test Post',
valid: true, valid: true,
passesCondition: true, passesCondition: true,
}
const currentState: Record<string, FieldState> = {
title: {
...title,
isModified: true,
}, },
computedTitle: { computedTitle: {
value: 'Test Post (computed on the client)', value: 'Test Post (computed on the client)',
@@ -582,17 +587,7 @@ describe('Form State', () => {
}, },
} }
const formStateAtTimeOfRequest = { const incomingStateFromServer: Record<string, FieldState> = {
...currentState,
title: {
value: 'Test Post (modified on the client 2)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer = {
title: { title: {
value: 'Test Post (modified on the server)', value: 'Test Post (modified on the server)',
initialValue: 'Test Post', initialValue: 'Test Post',
@@ -610,20 +605,30 @@ describe('Form State', () => {
const newState = mergeServerFormState({ const newState = mergeServerFormState({
acceptValues: true, acceptValues: true,
currentState, currentState,
formStateAtTimeOfRequest,
incomingState: incomingStateFromServer, incomingState: incomingStateFromServer,
}) })
expect(newState).toStrictEqual(incomingStateFromServer) expect(newState).toStrictEqual({
...incomingStateFromServer,
title: {
...incomingStateFromServer.title,
isModified: true,
},
})
}) })
it('should not accept values from the server if they have been modified locally since the request was made, e.g. on autosave', () => { it('should not accept values from the server if they have been modified locally since the request was made, e.g. on autosave', () => {
const currentState = { const title: FieldState = {
title: {
value: 'Test Post (modified on the client 1)', value: 'Test Post (modified on the client 1)',
initialValue: 'Test Post', initialValue: 'Test Post',
valid: true, valid: true,
passesCondition: true, passesCondition: true,
}
const currentState: Record<string, FieldState> = {
title: {
...title,
isModified: true,
}, },
computedTitle: { computedTitle: {
value: 'Test Post', value: 'Test Post',
@@ -633,17 +638,7 @@ describe('Form State', () => {
}, },
} }
const formStateAtTimeOfRequest = { const incomingStateFromServer: Record<string, FieldState> = {
...currentState,
title: {
value: 'Test Post (modified on the client 2)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer = {
title: { title: {
value: 'Test Post (modified on the server)', value: 'Test Post (modified on the server)',
initialValue: 'Test Post', initialValue: 'Test Post',
@@ -661,12 +656,15 @@ describe('Form State', () => {
const newState = mergeServerFormState({ const newState = mergeServerFormState({
acceptValues: { overrideLocalChanges: false }, acceptValues: { overrideLocalChanges: false },
currentState, currentState,
formStateAtTimeOfRequest,
incomingState: incomingStateFromServer, incomingState: incomingStateFromServer,
}) })
expect(newState).toStrictEqual({ expect(newState).toStrictEqual({
...currentState, ...currentState,
title: {
...currentState.title,
isModified: true,
},
computedTitle: incomingStateFromServer.computedTitle, // This field was not modified locally, so should be updated from the server computedTitle: incomingStateFromServer.computedTitle, // This field was not modified locally, so should be updated from the server
}) })
}) })

View File

@@ -68,6 +68,7 @@ export interface Config {
blocks: {}; blocks: {};
collections: { collections: {
posts: Post; posts: Post;
'autosave-posts': AutosavePost;
users: User; users: User;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
@@ -76,6 +77,7 @@ export interface Config {
collectionsJoins: {}; collectionsJoins: {};
collectionsSelect: { collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>; posts: PostsSelect<false> | PostsSelect<true>;
'autosave-posts': AutosavePostsSelect<false> | AutosavePostsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -120,6 +122,7 @@ export interface UserAuthOperations {
export interface Post { export interface Post {
id: string; id: string;
title?: string | null; title?: string | null;
computedTitle?: string | null;
renderTracker?: string | null; renderTracker?: string | null;
/** /**
* This field should only validate on submit. Try typing "Not allowed" and submitting the form. * This field should only validate on submit. Try typing "Not allowed" and submitting the form.
@@ -151,6 +154,18 @@ export interface Post {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-posts".
*/
export interface AutosavePost {
id: string;
title?: string | null;
computedTitle?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users". * via the `definition` "users".
@@ -166,6 +181,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;
} }
/** /**
@@ -179,6 +201,10 @@ export interface PayloadLockedDocument {
relationTo: 'posts'; relationTo: 'posts';
value: string | Post; value: string | Post;
} | null) } | null)
| ({
relationTo: 'autosave-posts';
value: string | AutosavePost;
} | null)
| ({ | ({
relationTo: 'users'; relationTo: 'users';
value: string | User; value: string | User;
@@ -231,6 +257,7 @@ export interface PayloadMigration {
*/ */
export interface PostsSelect<T extends boolean = true> { export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
computedTitle?: T;
renderTracker?: T; renderTracker?: T;
validateUsingEvent?: T; validateUsingEvent?: T;
blocks?: blocks?:
@@ -261,6 +288,17 @@ export interface PostsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-posts_select".
*/
export interface AutosavePostsSelect<T extends boolean = true> {
title?: T;
computedTitle?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select". * via the `definition` "users_select".
@@ -275,6 +313,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

View File

@@ -61,6 +61,14 @@ const AutosavePosts: CollectionConfig = {
beforeChange: [({ data }) => data?.title], beforeChange: [({ data }) => data?.title],
}, },
}, },
{
name: 'richText',
type: 'richText',
},
{
name: 'json',
type: 'json',
},
{ {
name: 'description', name: 'description',
label: 'Description', label: 'Description',

View File

@@ -198,6 +198,30 @@ export interface AutosavePost {
id: string; id: string;
title: string; title: string;
computedTitle?: string | null; computedTitle?: string | null;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
json?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
description: string; description: string;
array?: array?:
| { | {
@@ -793,6 +817,8 @@ export interface PostsSelect<T extends boolean = true> {
export interface AutosavePostsSelect<T extends boolean = true> { export interface AutosavePostsSelect<T extends boolean = true> {
title?: T; title?: T;
computedTitle?: T; computedTitle?: T;
richText?: T;
json?: T;
description?: T; description?: T;
array?: array?:
| T | T