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

@@ -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',
type: 'text',
},
{
name: 'computedTitle',
type: 'text',
hooks: {
beforeChange: [({ data }) => data?.title],
},
label: 'Computed Title',
},
{
name: 'renderTracker',
type: 'text',

View File

@@ -3,13 +3,14 @@ import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { AutosavePostsCollection } from './collections/Autosave/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection],
collections: [PostsCollection, AutosavePostsCollection],
admin: {
importMap: {
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 { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js'
import { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
@@ -21,6 +22,7 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.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'
const { describe, beforeEach, afterEach } = test
@@ -36,11 +38,13 @@ let serverURL: string
test.describe('Form State', () => {
let page: Page
let postsUrl: AdminUrlUtil
let autosavePostsUrl: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, postsSlug)
autosavePostsUrl = new AdminUrlUtil(serverURL, autosavePostsSlug)
context = await browser.newContext()
page = await context.newPage()
@@ -296,6 +300,59 @@ test.describe('Form State', () => {
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', () => {
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 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', () => {
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: {
value: 'Test Post (modified on the client)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
...title,
isModified: true,
},
computedTitle: {
value: 'Test Post (computed on the client)',
@@ -582,17 +587,7 @@ describe('Form State', () => {
},
}
const formStateAtTimeOfRequest = {
...currentState,
title: {
value: 'Test Post (modified on the client 2)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer = {
const incomingStateFromServer: Record<string, FieldState> = {
title: {
value: 'Test Post (modified on the server)',
initialValue: 'Test Post',
@@ -610,20 +605,30 @@ describe('Form State', () => {
const newState = mergeServerFormState({
acceptValues: true,
currentState,
formStateAtTimeOfRequest,
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', () => {
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: {
value: 'Test Post (modified on the client 1)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
...title,
isModified: true,
},
computedTitle: {
value: 'Test Post',
@@ -633,17 +638,7 @@ describe('Form State', () => {
},
}
const formStateAtTimeOfRequest = {
...currentState,
title: {
value: 'Test Post (modified on the client 2)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer = {
const incomingStateFromServer: Record<string, FieldState> = {
title: {
value: 'Test Post (modified on the server)',
initialValue: 'Test Post',
@@ -661,12 +656,15 @@ describe('Form State', () => {
const newState = mergeServerFormState({
acceptValues: { overrideLocalChanges: false },
currentState,
formStateAtTimeOfRequest,
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
})
})

View File

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

View File

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

View File

@@ -198,6 +198,30 @@ export interface AutosavePost {
id: string;
title: string;
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;
array?:
| {
@@ -793,6 +817,8 @@ export interface PostsSelect<T extends boolean = true> {
export interface AutosavePostsSelect<T extends boolean = true> {
title?: T;
computedTitle?: T;
richText?: T;
json?: T;
description?: T;
array?:
| T