fix(ui): significantly more predictable autosave form state (#13460)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,9 +120,11 @@ 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
|
||||||
returnLockStatus?: boolean
|
returnLockStatus?: boolean
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
DocumentPreferences,
|
DocumentPreferences,
|
||||||
Field as FieldSchema,
|
Field as FieldSchema,
|
||||||
FieldSchemaMap,
|
FieldSchemaMap,
|
||||||
FieldState,
|
|
||||||
FormState,
|
FormState,
|
||||||
FormStateWithoutComponents,
|
FormStateWithoutComponents,
|
||||||
PayloadRequest,
|
PayloadRequest,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
31
test/form-state/collections/Autosave/index.tsx
Normal file
31
test/form-state/collections/Autosave/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
value: 'Test Post (modified on the client)',
|
||||||
|
initialValue: 'Test Post',
|
||||||
|
valid: true,
|
||||||
|
passesCondition: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentState: Record<string, FieldState> = {
|
||||||
title: {
|
title: {
|
||||||
value: 'Test Post (modified on the client)',
|
...title,
|
||||||
initialValue: 'Test Post',
|
isModified: true,
|
||||||
valid: true,
|
|
||||||
passesCondition: 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 = {
|
||||||
|
value: 'Test Post (modified on the client 1)',
|
||||||
|
initialValue: 'Test Post',
|
||||||
|
valid: true,
|
||||||
|
passesCondition: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentState: Record<string, FieldState> = {
|
||||||
title: {
|
title: {
|
||||||
value: 'Test Post (modified on the client 1)',
|
...title,
|
||||||
initialValue: 'Test Post',
|
isModified: true,
|
||||||
valid: true,
|
|
||||||
passesCondition: 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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user