From cdd90f91c81b42a96d0605f02b53617824419c6e Mon Sep 17 00:00:00 2001 From: Sean Zubrickas Date: Thu, 14 Aug 2025 10:15:35 -0700 Subject: [PATCH 1/3] docs: updates image paths to new screenshots (#13461) Updates images paths for the following screenshots: - auth-overview.jpg - autosave-drafts.jpg - autosave-v3.jpg - uploads-overview.jpg - versions-v3.jpg --- docs/authentication/overview.mdx | 2 +- docs/upload/overview.mdx | 4 ++-- docs/versions/autosave.mdx | 2 +- docs/versions/drafts.mdx | 2 +- docs/versions/overview.mdx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index 7ff87de648..b108a43a6e 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -33,7 +33,7 @@ export const Users: CollectionConfig = { } ``` -![Authentication Admin Panel functionality](https://payloadcms.com/images/docs/auth-admin.jpg) +![Authentication Admin Panel functionality](https://payloadcms.com/images/docs/auth-overview.jpg) _Admin Panel screenshot depicting an Admins Collection with Auth enabled_ ## Config Options diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 6b0b058c38..1167c957bf 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -13,8 +13,8 @@ keywords: uploads, images, media, overview, documentation, Content Management Sy diff --git a/docs/versions/autosave.mdx b/docs/versions/autosave.mdx index bc3d2e91c3..b29606ea19 100644 --- a/docs/versions/autosave.mdx +++ b/docs/versions/autosave.mdx @@ -12,7 +12,7 @@ Extending on Payload's [Draft](/docs/versions/drafts) functionality, you can con Autosave relies on Versions and Drafts being enabled in order to function. -![Autosave Enabled](/images/docs/autosave-enabled.png) +![Autosave Enabled](/images/docs/autosave-v3.jpg) _If Autosave is enabled, drafts will be created automatically as the document is modified and the Admin UI adds an indicator describing when the document was last saved to the top right of the sidebar._ ## Options diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index fd98c00eca..4415766a9b 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -14,7 +14,7 @@ Payload's Draft functionality builds on top of the Versions functionality to all By enabling Versions with Drafts, your collections and globals can maintain _newer_, and _unpublished_ versions of your documents. It's perfect for cases where you might want to work on a document, update it and save your progress, but not necessarily make it publicly published right away. Drafts are extremely helpful when building preview implementations. -![Drafts Enabled](/images/docs/drafts-enabled.png) +![Drafts Enabled](/images/docs/autosave-drafts.jpg) _If Drafts are enabled, the typical Save button is replaced with new actions which allow you to either save a draft, or publish your changes._ ## Options diff --git a/docs/versions/overview.mdx b/docs/versions/overview.mdx index 6573d835f8..62b0c6f9c7 100644 --- a/docs/versions/overview.mdx +++ b/docs/versions/overview.mdx @@ -13,7 +13,7 @@ keywords: version history, revisions, audit log, draft, publish, restore, autosa When enabled, Payload will automatically scaffold a new Collection in your database to store versions of your document(s) over time, and the Admin UI will be extended with additional views that allow you to browse document versions, view diffs in order to see exactly what has changed in your documents (and when they changed), and restore documents back to prior versions easily. -![Versions](/images/docs/versions.png) +![Versions](/images/docs/versions-v3.jpg) _Comparing an old version to a newer version of a document_ **With Versions, you can:** From 46699ec314e8303341fd51817fbece46c22cbfb4 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Thu, 14 Aug 2025 20:17:17 +0300 Subject: [PATCH 2/3] test: skip cookies filter for internal URLs in `getExternalFile` (#13476) Test for https://github.com/payloadcms/payload/pull/13475 --- test/uploads/int.spec.ts | 50 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts index 9d38a35706..435e2b6649 100644 --- a/test/uploads/int.spec.ts +++ b/test/uploads/int.spec.ts @@ -1,15 +1,19 @@ +import type { AddressInfo } from 'net' import type { CollectionSlug, Payload } from 'payload' import { randomUUID } from 'crypto' import fs from 'fs' +import { createServer } from 'http' import path from 'path' -import { _internal_safeFetchGlobal, getFileByPath } from 'payload' +import { _internal_safeFetchGlobal, createPayloadRequest, getFileByPath } from 'payload' import { fileURLToPath } from 'url' import { promisify } from 'util' import type { NextRESTClient } from '../helpers/NextRESTClient.js' import type { Enlarge, Media } from './payload-types.js' +// eslint-disable-next-line payload/no-relative-monorepo-imports +import { getExternalFile } from '../../packages/payload/src/uploads/getExternalFile.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { createStreamableFile } from './createStreamableFile.js' import { @@ -632,6 +636,50 @@ describe('Collections - Uploads', () => { fetchSpy.mockRestore() }) + it('getExternalFile should not filter out payload cookies when externalFileHeaderFilter is not defined and the URL is not external', async () => { + const testCookies = ['payload-token=123', 'other-cookie=456', 'payload-something=789'].join( + '; ', + ) + + const fetchSpy = jest.spyOn(global, 'fetch') + + // spin up a temporary server so fetch to the local doesn't fail + const server = createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + }) + await new Promise((res) => server.listen(0, undefined, undefined, res)) + + const port = (server.address() as AddressInfo).port + const baseUrl = `http://localhost:${port}` + + const req = await createPayloadRequest({ + config: payload.config, + request: new Request(baseUrl, { + headers: new Headers({ + cookie: testCookies, + origin: baseUrl, + }), + }), + }) + + await getExternalFile({ + data: { url: '/api/media/image.png' }, + req, + uploadConfig: { skipSafeFetch: true }, + }) + + const [[, options]] = fetchSpy.mock.calls + const cookieHeader = options.headers.cookie + + expect(cookieHeader).toContain('payload-token=123') + expect(cookieHeader).toContain('payload-something=789') + expect(cookieHeader).toContain('other-cookie=456') + + fetchSpy.mockRestore() + await new Promise((res) => server.close(res)) + }) + it('should keep all cookies when externalFileHeaderFilter is defined', async () => { const testCookies = ['payload-token=123', 'other-cookie=456', 'payload-something=789'].join( '; ', From 0b60bf2eff4af148174cd77bdba38e06010bc390 Mon Sep 17 00:00:00 2001 From: jacobsfletch Date: Thu, 14 Aug 2025 19:36:02 -0400 Subject: [PATCH 3/3] fix(ui): significantly more predictable autosave form state (#13460) --- docs/performance/overview.mdx | 2 +- packages/payload/src/admin/forms/Form.ts | 14 +++- packages/ui/src/elements/Autosave/index.tsx | 4 +- .../src/elements/DocumentDrawer/Provider.tsx | 2 +- packages/ui/src/forms/Form/fieldReducer.ts | 13 +++- packages/ui/src/forms/Form/index.tsx | 19 +++-- .../ui/src/forms/Form/mergeServerFormState.ts | 9 ++- packages/ui/src/forms/Form/types.ts | 23 +++--- .../forms/fieldSchemasToFormState/index.tsx | 2 +- .../fieldSchemasToFormState/iterateFields.ts | 1 - packages/ui/src/utilities/buildFormState.ts | 2 + packages/ui/src/views/Edit/index.tsx | 16 +++-- .../form-state/collections/Autosave/index.tsx | 31 ++++++++ test/form-state/collections/Posts/index.ts | 8 +++ test/form-state/config.ts | 3 +- test/form-state/e2e.spec.ts | 57 +++++++++++++++ test/form-state/int.spec.ts | 70 +++++++++---------- test/form-state/payload-types.ts | 45 ++++++++++++ test/versions/collections/Autosave.ts | 8 +++ test/versions/payload-types.ts | 26 +++++++ 20 files changed, 278 insertions(+), 77 deletions(-) create mode 100644 test/form-state/collections/Autosave/index.tsx diff --git a/docs/performance/overview.mdx b/docs/performance/overview.mdx index 274312d4ce..9756ca45f1 100644 --- a/docs/performance/overview.mdx +++ b/docs/performance/overview.mdx @@ -207,7 +207,7 @@ Everything mentioned above applies to local development as well, but there are a ### Enable Turbopack - **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. Add `--turbo` to your dev script to significantly speed up your local development server start time. diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index 42e5cfebc9..97dbc5589e 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -56,6 +56,12 @@ export type FieldState = { fieldSchema?: Field filterOptions?: FilterOptionsResult 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. * This is used to denote if a field has been rendered, and if so, @@ -114,9 +120,11 @@ export type BuildFormStateArgs = { mockRSCs?: boolean operation?: 'create' | 'update' 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 req: PayloadRequest returnLockStatus?: boolean diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 96b121792d..54628cc8c7 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -149,7 +149,9 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate if (!skipSubmission && modifiedRef.current && url) { - const result = await submit({ + const result = await submit<{ + incrementVersionCount: boolean + }>({ acceptValues: { overrideLocalChanges: false, }, diff --git a/packages/ui/src/elements/DocumentDrawer/Provider.tsx b/packages/ui/src/elements/DocumentDrawer/Provider.tsx index 1bc4669053..a4bdf7dd18 100644 --- a/packages/ui/src/elements/DocumentDrawer/Provider.tsx +++ b/packages/ui/src/elements/DocumentDrawer/Provider.tsx @@ -21,7 +21,7 @@ export type DocumentDrawerContextProps = { readonly onSave?: (args: { 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. */ context?: Record diff --git a/packages/ui/src/forms/Form/fieldReducer.ts b/packages/ui/src/forms/Form/fieldReducer.ts index a2910cee89..3dfadf09f1 100644 --- a/packages/ui/src/forms/Form/fieldReducer.ts +++ b/packages/ui/src/forms/Form/fieldReducer.ts @@ -189,12 +189,11 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState { } case 'MERGE_SERVER_STATE': { - const { acceptValues, formStateAtTimeOfRequest, prevStateRef, serverState } = action + const { acceptValues, prevStateRef, serverState } = action const newState = mergeServerFormState({ acceptValues, currentState: state || {}, - formStateAtTimeOfRequest, incomingState: serverState, }) @@ -385,6 +384,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState { return { ...field, [key]: value, + ...(key === 'value' ? { isModified: true } : {}), } } @@ -398,6 +398,15 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState { [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 } diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index aa545e4999..59d5006f19 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -264,23 +264,21 @@ export const Form: React.FC = (props) => { 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) // Execute server side validations if (Array.isArray(beforeSubmit)) { + const serializableFormState = deepCopyObjectSimpleWithoutReactComponents( + contextRef.current.fields, + ) + let revalidatedFormState: FormState await beforeSubmit.reduce(async (priorOnChange, beforeSubmitFn) => { await priorOnChange const result = await beforeSubmitFn({ - formState: formStateCopy, + formState: serializableFormState, }) revalidatedFormState = result @@ -328,7 +326,7 @@ export const Form: React.FC = (props) => { data[key] = value } - onSubmit(formStateCopy, data) + onSubmit(contextRef.current.fields, data) } if (!hasFormSubmitAction) { @@ -379,13 +377,14 @@ export const Form: React.FC = (props) => { if (res.status < 400) { if (typeof onSuccess === 'function') { - const newFormState = await onSuccess(json, context) + const newFormState = await onSuccess(json, { + context, + }) if (newFormState) { dispatchFields({ type: 'MERGE_SERVER_STATE', acceptValues, - formStateAtTimeOfRequest: formStateCopy, prevStateRef: prevFormState, serverState: newFormState, }) diff --git a/packages/ui/src/forms/Form/mergeServerFormState.ts b/packages/ui/src/forms/Form/mergeServerFormState.ts index adfd6062ff..2c1f2bf36a 100644 --- a/packages/ui/src/forms/Form/mergeServerFormState.ts +++ b/packages/ui/src/forms/Form/mergeServerFormState.ts @@ -21,7 +21,6 @@ export type AcceptValues = type Args = { acceptValues?: AcceptValues currentState?: FormState - formStateAtTimeOfRequest?: FormState incomingState: FormState } @@ -37,7 +36,6 @@ type Args = { export const mergeServerFormState = ({ acceptValues, currentState = {}, - formStateAtTimeOfRequest, incomingState, }: Args): FormState => { const newState = { ...currentState } @@ -49,8 +47,9 @@ export const mergeServerFormState = ({ /** * 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. - * Can also control this granularly by only accepting unmodified values, e.g. for autosave. + * Otherwise: + * a. accept all values when explicitly requested, e.g. on submit + * b. only accept values for unmodified fields, e.g. on autosave */ if ( !incomingField.addedByServer && @@ -59,7 +58,7 @@ export const mergeServerFormState = ({ (typeof acceptValues === 'object' && acceptValues !== null && acceptValues?.overrideLocalChanges === false && - currentState[path]?.value !== formStateAtTimeOfRequest?.[path]?.value)) + currentState[path].isModified)) ) { delete incomingField.value delete incomingField.initialValue diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index a52ed34662..d6047ef01c 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -54,7 +54,15 @@ export type FormProps = { log?: boolean onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise)[] onSubmit?: (fields: FormState, data: Data) => void - onSuccess?: (json: unknown, context?: Record) => Promise | void + onSuccess?: ( + json: unknown, + options?: { + /** + * Arbitrary context passed to the onSuccess callback. + */ + context?: Record + }, + ) => Promise | void redirect?: string submitted?: boolean uuid?: string @@ -70,14 +78,14 @@ export type FormProps = { } ) -export type SubmitOptions = { +export type SubmitOptions> = { acceptValues?: AcceptValues 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. */ - context?: Record + context?: T /** * When true, will disable the form while it is processing. * @default true @@ -99,11 +107,11 @@ export type SubmitOptions = { export type DispatchFields = React.Dispatch -export type Submit = ( - options?: SubmitOptions, +export type Submit = >( + options?: SubmitOptions, e?: React.FormEvent, ) => Promise @@ -185,7 +193,6 @@ export type ADD_ROW = { export type MERGE_SERVER_STATE = { acceptValues?: AcceptValues - formStateAtTimeOfRequest?: FormState prevStateRef: React.RefObject serverState: FormState type: 'MERGE_SERVER_STATE' diff --git a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx index f1f7f47259..741f139dce 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx +++ b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx @@ -5,7 +5,6 @@ import type { DocumentPreferences, Field, FieldSchemaMap, - FieldState, FormState, FormStateWithoutComponents, PayloadRequest, @@ -105,6 +104,7 @@ export const fieldSchemasToFormState = async ({ skipValidation, }: Args): Promise => { if (!clientFieldSchemaMap && renderFieldFn) { + // eslint-disable-next-line no-console console.warn( 'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance', ) diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts index 026af2715e..766a47bf9b 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts @@ -5,7 +5,6 @@ import type { DocumentPreferences, Field as FieldSchema, FieldSchemaMap, - FieldState, FormState, FormStateWithoutComponents, PayloadRequest, diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index 1c718d1e5d..f8ab5c21e0 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -182,11 +182,13 @@ export const buildFormState = async ( } let documentData = undefined + if (documentFormState) { documentData = reduceFieldsToValues(documentFormState, true) } let blockData = initialBlockData + if (initialBlockFormState) { blockData = reduceFieldsToValues(initialBlockFormState, true) } diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 1e78cf46ac..15e8f8fb48 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-compiler/react-compiler -- TODO: fix */ 'use client' -import type { ClientUser, DocumentViewClientProps, FormState } from 'payload' +import type { ClientUser, DocumentViewClientProps } from 'payload' import { useRouter, useSearchParams } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' @@ -256,10 +256,13 @@ export function DefaultEditView({ user?.id, ]) - const onSave = useCallback( - async (json, context?: Record): Promise => { + const onSave = useCallback( + async (json, options) => { + const { context } = options || {} + const controller = handleAbortRef(abortOnSaveRef) + // @ts-expect-error can ignore const document = json?.doc || json?.result const updatedAt = document?.updatedAt || new Date().toISOString() @@ -290,9 +293,10 @@ export function DefaultEditView({ const operation = id ? 'update' : 'create' void onSaveFromContext({ - ...json, + ...(json as Record), context, operation, + // @ts-expect-error todo: this is not right, should be under `doc`? updatedAt: operation === 'update' ? new Date().toISOString() @@ -397,13 +401,11 @@ export function DefaultEditView({ formState: prevFormState, globalSlug, 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, returnLockStatus: isLockingEnabled, schemaPath: schemaPathSegments.join('.'), signal: controller.signal, + skipValidation: !submitted, updateLastEdited, }) diff --git a/test/form-state/collections/Autosave/index.tsx b/test/form-state/collections/Autosave/index.tsx new file mode 100644 index 0000000000..939b4c463d --- /dev/null +++ b/test/form-state/collections/Autosave/index.tsx @@ -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, + }, + }, + }, +} diff --git a/test/form-state/collections/Posts/index.ts b/test/form-state/collections/Posts/index.ts index 54183c26f7..da28f93209 100644 --- a/test/form-state/collections/Posts/index.ts +++ b/test/form-state/collections/Posts/index.ts @@ -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', diff --git a/test/form-state/config.ts b/test/form-state/config.ts index 07145a7052..61338952bd 100644 --- a/test/form-state/config.ts +++ b/test/form-state/config.ts @@ -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), diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts index c71ad322b7..f141d96d0d 100644 --- a/test/form-state/e2e.spec.ts +++ b/test/form-state/e2e.spec.ts @@ -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 diff --git a/test/form-state/int.spec.ts b/test/form-state/int.spec.ts index a417b41f86..71a36db3c0 100644 --- a/test/form-state/int.spec.ts +++ b/test/form-state/int.spec.ts @@ -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 = { 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 = { 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 = { 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 = { 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 }) }) diff --git a/test/form-state/payload-types.ts b/test/form-state/payload-types.ts index dd00cd42ef..adfb458a7e 100644 --- a/test/form-state/payload-types.ts +++ b/test/form-state/payload-types.ts @@ -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 | PostsSelect; + 'autosave-posts': AutosavePostsSelect | AutosavePostsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -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 { title?: T; + computedTitle?: T; renderTracker?: T; validateUsingEvent?: T; blocks?: @@ -261,6 +288,17 @@ export interface PostsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "autosave-posts_select". + */ +export interface AutosavePostsSelect { + 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 { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/test/versions/collections/Autosave.ts b/test/versions/collections/Autosave.ts index fff4062231..5f0df3b648 100644 --- a/test/versions/collections/Autosave.ts +++ b/test/versions/collections/Autosave.ts @@ -61,6 +61,14 @@ const AutosavePosts: CollectionConfig = { beforeChange: [({ data }) => data?.title], }, }, + { + name: 'richText', + type: 'richText', + }, + { + name: 'json', + type: 'json', + }, { name: 'description', label: 'Description', diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index 5090bd68d7..52f8bec834 100644 --- a/test/versions/payload-types.ts +++ b/test/versions/payload-types.ts @@ -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 { export interface AutosavePostsSelect { title?: T; computedTitle?: T; + richText?: T; + json?: T; description?: T; array?: | T