fix(ui): significantly more predictable autosave form state (#13460)
This commit is contained in:
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',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'computedTitle',
|
||||
type: 'text',
|
||||
hooks: {
|
||||
beforeChange: [({ data }) => data?.title],
|
||||
},
|
||||
label: 'Computed Title',
|
||||
},
|
||||
{
|
||||
name: 'renderTracker',
|
||||
type: 'text',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,6 +61,14 @@ const AutosavePosts: CollectionConfig = {
|
||||
beforeChange: [({ data }) => data?.title],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
type: 'json',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user