From 1d81b0c6dd7bcc8b1a47037d8bae6f94893a830c Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 11 Aug 2025 16:59:03 -0400 Subject: [PATCH] fix(ui): autosave hooks are not reflected in form state (#13416) Fixes #10515. Needed for #12956. Hooks run within autosave are not reflected in form state. Similar to #10268, but for autosave events. For example, if you are using a computed value, like this: ```ts [ // ... { name: 'title', type: 'text', }, { name: 'computedTitle', type: 'text', hooks: { beforeChange: [({ data }) => data?.title], }, }, ] ``` In the example above, when an autosave event is triggered after changing the `title` field, we expect the `computedTitle` field to match. But although this takes place on the database level, the UI does not reflect this change unless you refresh the page or navigate back and forth. Here's an example: Before: https://github.com/user-attachments/assets/c8c68a78-9957-45a8-a710-84d954d15bcc After: https://github.com/user-attachments/assets/16cb87a5-83ca-4891-b01f-f5c4b0a34362 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1210561273449855 --- docs/admin/react-hooks.mdx | 2 +- packages/ui/src/elements/Autosave/index.tsx | 146 ++++-------------- .../src/elements/DocumentDrawer/Provider.tsx | 5 + packages/ui/src/elements/Upload/index.tsx | 32 ++-- packages/ui/src/forms/Form/index.tsx | 31 +++- packages/ui/src/forms/Form/types.ts | 28 +++- .../ui/src/providers/DocumentInfo/index.tsx | 23 ++- .../ui/src/providers/DocumentInfo/types.ts | 21 ++- .../ui/src/providers/DocumentTitle/index.tsx | 7 +- packages/ui/src/views/Edit/index.tsx | 42 +++-- test/_community/payload-types.ts | 26 ++-- test/versions/collections/Autosave.ts | 8 + test/versions/e2e.spec.ts | 38 +++++ test/versions/payload-types.ts | 6 +- 14 files changed, 214 insertions(+), 201 deletions(-) diff --git a/docs/admin/react-hooks.mdx b/docs/admin/react-hooks.mdx index 5640f185c..2d021daf2 100644 --- a/docs/admin/react-hooks.mdx +++ b/docs/admin/react-hooks.mdx @@ -739,7 +739,7 @@ The `useDocumentInfo` hook provides information about the current document being | **`lastUpdateTime`** | Timestamp of the last update to the document. | | **`mostRecentVersionIsAutosaved`** | Whether the most recent version is an autosaved version. | | **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences. [More details](./preferences). | -| **`savedDocumentData`** | The saved data of the document. | +| **`data`** | The saved data of the document. | | **`setDocFieldPreferences`** | Method to set preferences for a specific field. [More details](./preferences). | | **`setDocumentTitle`** | Method to set the document title. | | **`setHasPublishedDoc`** | Method to update whether the document has been published. | diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 2215f7e65..f341efa21 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -5,7 +5,6 @@ import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' import { dequal } from 'dequal/lite' import { reduceFieldsToValues, versionDefaults } from 'payload/shared' import React, { useDeferredValue, useEffect, useRef, useState } from 'react' -import { toast } from 'sonner' import { useAllFormFields, @@ -17,13 +16,11 @@ import { useDebounce } from '../../hooks/useDebounce.js' import { useEffectEvent } from '../../hooks/useEffectEvent.js' import { useQueues } from '../../hooks/useQueues.js' import { useConfig } from '../../providers/Config/index.js' -import { useDocumentEvents } from '../../providers/DocumentEvents/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { formatTimeToNow } from '../../utilities/formatDocTitle/formatDateTitle.js' import { reduceFieldsToValuesWithValidation } from '../../utilities/reduceFieldsToValuesWithValidation.js' -import { useDocumentDrawerContext } from '../DocumentDrawer/Provider.js' import { LeaveWithoutSaving } from '../LeaveWithoutSaving/index.js' import './index.scss' @@ -51,16 +48,11 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) incrementVersionCount, lastUpdateTime, mostRecentVersionIsAutosaved, - setLastUpdateTime, setMostRecentVersionIsAutosaved, setUnpublishedVersionCount, - updateSavedDocumentData, } = useDocumentInfo() - const { onSave: onSaveFromDocumentDrawer } = useDocumentDrawerContext() - - const { reportUpdate } = useDocumentEvents() - const { dispatchFields, isValid, setBackgroundProcessing, setIsValid } = useForm() + const { isValid, setBackgroundProcessing, submit } = useForm() const [formState] = useAllFormFields() const modified = useFormModified() @@ -151,118 +143,38 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) method = 'POST' } - if (url) { - if (modifiedRef.current) { - const { data, valid } = reduceFieldsToValuesWithValidation(formStateRef.current, true) + const { valid } = reduceFieldsToValuesWithValidation(formStateRef.current, true) - data._status = 'draft' + const skipSubmission = + submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate - const skipSubmission = - submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate + if (!skipSubmission && modifiedRef.current && url) { + const result = await submit({ + action: url, + context: { + incrementVersionCount: false, + }, + disableFormWhileProcessing: false, + disableSuccessStatus: true, + method, + overrides: { + _status: 'draft', + }, + skipValidation: versionsConfig?.drafts && !versionsConfig?.drafts?.validate, + }) - if (!skipSubmission) { - let res - - try { - res = await fetch(url, { - body: JSON.stringify(data), - credentials: 'include', - headers: { - 'Accept-Language': i18n.language, - 'Content-Type': 'application/json', - }, - method, - }) - } catch (_err) { - // Swallow Error - } - - const newDate = new Date() - // We need to log the time in order to figure out if we need to trigger the state off later - endTimestamp = newDate.getTime() - - const json = await res.json() - - if (res.status === 200) { - setLastUpdateTime(newDate.getTime()) - - reportUpdate({ - id, - entitySlug, - updatedAt: newDate.toISOString(), - }) - - // if onSaveFromDocumentDrawer is defined, call it - if (typeof onSaveFromDocumentDrawer === 'function') { - void onSaveFromDocumentDrawer({ - ...json, - operation: 'update', - }) - } - - if (!mostRecentVersionIsAutosaved) { - incrementVersionCount() - setMostRecentVersionIsAutosaved(true) - setUnpublishedVersionCount((prev) => prev + 1) - } - } - - if (versionsConfig?.drafts && versionsConfig?.drafts?.validate && json?.errors) { - if (Array.isArray(json.errors)) { - const [fieldErrors, nonFieldErrors] = json.errors.reduce( - ([fieldErrs, nonFieldErrs], err) => { - const newFieldErrs = [] - const newNonFieldErrs = [] - - if (err?.message) { - newNonFieldErrs.push(err) - } - - if (Array.isArray(err?.data)) { - err.data.forEach((dataError) => { - if (dataError?.field) { - newFieldErrs.push(dataError) - } else { - newNonFieldErrs.push(dataError) - } - }) - } - - return [ - [...fieldErrs, ...newFieldErrs], - [...nonFieldErrs, ...newNonFieldErrs], - ] - }, - [[], []], - ) - - dispatchFields({ - type: 'ADD_SERVER_ERRORS', - errors: fieldErrors, - }) - - nonFieldErrors.forEach((err) => { - toast.error(err.message || i18n.t('error:unknown')) - }) - - setIsValid(false) - hideIndicator() - return - } - } else { - // If it's not an error then we can update the document data inside the context - const document = json?.doc || json?.result - - // Manually update the data since this function doesn't fire the `submit` function from useForm - if (document) { - setIsValid(true) - updateSavedDocumentData(document) - } - } - - hideIndicator() - } + if (result && result?.res?.ok && !mostRecentVersionIsAutosaved) { + incrementVersionCount() + setMostRecentVersionIsAutosaved(true) + setUnpublishedVersionCount((prev) => prev + 1) } + + const newDate = new Date() + + // We need to log the time in order to figure out if we need to trigger the state off later + endTimestamp = newDate.getTime() + + hideIndicator() } } }, diff --git a/packages/ui/src/elements/DocumentDrawer/Provider.tsx b/packages/ui/src/elements/DocumentDrawer/Provider.tsx index 6b6c8c2b5..1bc466905 100644 --- a/packages/ui/src/elements/DocumentDrawer/Provider.tsx +++ b/packages/ui/src/elements/DocumentDrawer/Provider.tsx @@ -20,6 +20,11 @@ export type DocumentDrawerContextProps = { }) => Promise | void readonly onSave?: (args: { collectionConfig?: ClientCollectionConfig + /** + * @experimental - Note: this property is experimental and may change in the future. Use as your own discretion. + * If you want to pass additional data to the onSuccess callback, you can use this context object. + */ + context?: Record doc: TypeWithID operation: 'create' | 'update' result: Data diff --git a/packages/ui/src/elements/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index e9f0c9271..475a9efd2 100644 --- a/packages/ui/src/elements/Upload/index.tsx +++ b/packages/ui/src/elements/Upload/index.tsx @@ -161,7 +161,7 @@ export const Upload_v4: React.FC = (props) => { const { t } = useTranslation() const { setModified } = useForm() - const { id, docPermissions, savedDocumentData, setUploadStatus } = useDocumentInfo() + const { id, data, docPermissions, setUploadStatus } = useDocumentInfo() const isFormSubmitting = useFormProcessing() const { errorMessage, setValue, showError, value } = useField({ path: 'file', @@ -349,7 +349,7 @@ export const Upload_v4: React.FC = (props) => { const acceptMimeTypes = uploadConfig.mimeTypes?.join(', ') - const imageCacheTag = uploadConfig?.cacheTags && savedDocumentData?.updatedAt + const imageCacheTag = uploadConfig?.cacheTags && data?.updatedAt useEffect(() => { const handleControlFileUrl = async () => { @@ -375,11 +375,11 @@ export const Upload_v4: React.FC = (props) => { return (
- {savedDocumentData && savedDocumentData.filename && !removedFile && ( + {data && data.filename && !removedFile && ( = (props) => { uploadConfig={uploadConfig} /> )} - {((!uploadConfig.hideFileInputOnCreate && !savedDocumentData?.filename) || removedFile) && ( + {((!uploadConfig.hideFileInputOnCreate && !data?.filename) || removedFile) && (
{!value && !showUrlInput && ( @@ -506,7 +506,7 @@ export const Upload_v4: React.FC = (props) => {
@@ -523,17 +523,17 @@ export const Upload_v4: React.FC = (props) => { )}
)} - {(value || savedDocumentData?.filename) && ( + {(value || data?.filename) && ( = (props) => { )} - {savedDocumentData && hasImageSizes && ( + {data && hasImageSizes && ( - + )} diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 79e12b29f..ddb1cb115 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -18,6 +18,7 @@ import type { Context as FormContextType, FormProps, GetDataByPath, + Submit, SubmitOptions, } from './types.js' @@ -199,14 +200,19 @@ export const Form: React.FC = (props) => { return isValid }, [collectionSlug, config, dispatchFields, id, operation, t, user, documentForm]) - const submit = useCallback( - async (options: SubmitOptions = {}, e): Promise => { + const submit = useCallback( + async (options, e) => { const { action: actionArg = action, + context, + disableFormWhileProcessing = true, + disableSuccessStatus: disableSuccessStatusFromArgs, method: methodToUse = method, overrides: overridesFromArgs = {}, skipValidation, - } = options + } = options || ({} as SubmitOptions) + + const disableToast = disableSuccessStatusFromArgs ?? disableSuccessStatus if (disabled) { if (e) { @@ -217,6 +223,7 @@ export const Form: React.FC = (props) => { // create new toast promise which will resolve manually later let errorToast, successToast + const promise = new Promise((resolve, reject) => { successToast = resolve errorToast = reject @@ -225,7 +232,7 @@ export const Form: React.FC = (props) => { const hasFormSubmitAction = actionArg || typeof action === 'string' || typeof action === 'function' - if (redirect || disableSuccessStatus || !hasFormSubmitAction) { + if (redirect || disableToast || !hasFormSubmitAction) { // Do not show submitting toast, as the promise toast may never disappear under these conditions. // Instead, make successToast() or errorToast() throw toast.success / toast.error successToast = (data) => toast.success(data) @@ -247,8 +254,10 @@ export const Form: React.FC = (props) => { e.preventDefault() } - setProcessing(true) - setDisabled(true) + if (disableFormWhileProcessing) { + setProcessing(true) + setDisabled(true) + } if (waitForAutocomplete) { await wait(100) @@ -290,6 +299,7 @@ export const Form: React.FC = (props) => { skipValidation || disableValidationOnSubmit ? true : await contextRef.current.validateForm() setIsValid(isValid) + // If not valid, prevent submission if (!isValid) { errorToast(t('error:correctInvalidFields')) @@ -366,9 +376,10 @@ export const Form: React.FC = (props) => { if (isJSON) { json = await res.json() } + if (res.status < 400) { if (typeof onSuccess === 'function') { - const newFormState = await onSuccess(json) + const newFormState = await onSuccess(json, context) if (newFormState) { dispatchFields({ @@ -379,12 +390,13 @@ export const Form: React.FC = (props) => { }) } } + setSubmitted(false) setProcessing(false) if (redirect) { startRouteTransition(() => router.push(redirect)) - } else if (!disableSuccessStatus) { + } else if (!disableToast) { successToast(json.message || t('general:submissionSuccessful')) } } else { @@ -392,6 +404,7 @@ export const Form: React.FC = (props) => { setSubmitted(true) contextRef.current = { ...contextRef.current } // triggers rerender of all components that subscribe to form + if (json.message) { errorToast(json.message) return @@ -443,6 +456,8 @@ export const Form: React.FC = (props) => { errorToast(message) } + + return { formState: contextRef.current.fields, res } } catch (err) { console.error('Error submitting form', err) // eslint-disable-line no-console setProcessing(false) diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index 9cfd1ef64..a10350506 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -52,7 +52,7 @@ export type FormProps = { log?: boolean onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise)[] onSubmit?: (fields: FormState, data: Data) => void - onSuccess?: (json: unknown) => Promise | void + onSuccess?: (json: unknown, context?: Record) => Promise | void redirect?: string submitted?: boolean uuid?: string @@ -70,16 +70,40 @@ export type FormProps = { export type SubmitOptions = { action?: string + /** + * @experimental - Note: this property is experimental and may change in the future. Use as your own discretion. + * If you want to pass additional data to the onSuccess callback, you can use this context object. + */ + context?: Record + /** + * When true, will disable the form while it is processing. + * @default true + */ + disableFormWhileProcessing?: boolean + /** + * When true, will disable the success toast after form submission. + * @default false + */ + disableSuccessStatus?: boolean method?: string overrides?: ((formState) => FormData) | Record + /** + * When true, will skip validation before submitting the form. + * @default false + */ skipValidation?: boolean } export type DispatchFields = React.Dispatch + export type Submit = ( options?: SubmitOptions, e?: React.FormEvent, -) => Promise +) => Promise export type ValidateForm = () => Promise diff --git a/packages/ui/src/providers/DocumentInfo/index.tsx b/packages/ui/src/providers/DocumentInfo/index.tsx index 0fdb4d932..12ad46464 100644 --- a/packages/ui/src/providers/DocumentInfo/index.tsx +++ b/packages/ui/src/providers/DocumentInfo/index.tsx @@ -97,6 +97,7 @@ const DocumentInfo: React.FC< const [versionCount, setVersionCount] = useState(versionCountFromProps) const [hasPublishedDoc, setHasPublishedDoc] = useState(hasPublishedDocFromProps) + const [unpublishedVersionCount, setUnpublishedVersionCount] = useState( unpublishedVersionCountFromProps, ) @@ -104,11 +105,14 @@ const DocumentInfo: React.FC< const [documentIsLocked, setDocumentIsLocked] = useControllableState( isLockedFromProps, ) + const [currentEditor, setCurrentEditor] = useControllableState( currentEditorFromProps, ) const [lastUpdateTime, setLastUpdateTime] = useControllableState(lastUpdateTimeFromProps) - const [savedDocumentData, setSavedDocumentData] = useControllableState(initialData) + + const [data, setData] = useControllableState(initialData) + const [uploadStatus, setUploadStatus] = useControllableState<'failed' | 'idle' | 'uploading'>( 'idle', ) @@ -294,13 +298,6 @@ const DocumentInfo: React.FC< } }, [collectionConfig, globalConfig, versionCount]) - const updateSavedDocumentData = React.useCallback( - (json) => { - setSavedDocumentData(json) - }, - [setSavedDocumentData], - ) - /** * @todo: Remove this in v4 * Users should use the `DocumentTitleContext` instead. @@ -309,14 +306,14 @@ const DocumentInfo: React.FC< setDocumentTitle( formatDocTitle({ collectionConfig, - data: { ...savedDocumentData, id }, + data: { ...data, id }, dateFormat, fallback: id?.toString(), globalConfig, i18n, }), ) - }, [collectionConfig, globalConfig, savedDocumentData, dateFormat, i18n, id]) + }, [collectionConfig, globalConfig, data, dateFormat, i18n, id]) // clean on unmount useEffect(() => { @@ -351,6 +348,7 @@ const DocumentInfo: React.FC< ...props, action, currentEditor, + data, docConfig, docPermissions, documentIsLocked, @@ -367,8 +365,9 @@ const DocumentInfo: React.FC< lastUpdateTime, mostRecentVersionIsAutosaved, preferencesKey, - savedDocumentData, + savedDocumentData: data, setCurrentEditor, + setData, setDocFieldPreferences, setDocumentIsLocked, setDocumentTitle, @@ -381,7 +380,7 @@ const DocumentInfo: React.FC< unlockDocument, unpublishedVersionCount, updateDocumentEditor, - updateSavedDocumentData, + updateSavedDocumentData: setData, uploadStatus, versionCount, } diff --git a/packages/ui/src/providers/DocumentInfo/types.ts b/packages/ui/src/providers/DocumentInfo/types.ts index 7f9233e36..4d8d6404c 100644 --- a/packages/ui/src/providers/DocumentInfo/types.ts +++ b/packages/ui/src/providers/DocumentInfo/types.ts @@ -49,6 +49,7 @@ export type DocumentInfoProps = { export type DocumentInfoContext = { currentEditor?: ClientUser | null | number | string + data?: Data docConfig?: ClientCollectionConfig | ClientGlobalConfig documentIsLocked?: boolean documentLockState: React.RefObject<{ @@ -61,21 +62,26 @@ export type DocumentInfoContext = { incrementVersionCount: () => void isInitializing: boolean preferencesKey?: string + /** + * @deprecated This property is deprecated and will be removed in v4. + * Use `data` instead. + */ savedDocumentData?: Data setCurrentEditor?: React.Dispatch> + setData: (data: Data) => void setDocFieldPreferences: ( field: string, fieldPreferences: { [key: string]: unknown } & Partial, ) => void setDocumentIsLocked?: React.Dispatch> /** - * * @deprecated This property is deprecated and will be removed in v4. - * This is for performance reasons. Use the `DocumentTitleContext` instead. + * This is for performance reasons. Use the `DocumentTitleContext` instead + * via the `useDocumentTitle` hook. * @example * ```tsx * import { useDocumentTitle } from '@payloadcms/ui' - * const { setDocumentTitle } = useDocumentTitle() + * const { setDocumentTitle } = useDocumentTitle() * ``` */ setDocumentTitle: React.Dispatch> @@ -86,17 +92,22 @@ export type DocumentInfoContext = { setUploadStatus?: (status: 'failed' | 'idle' | 'uploading') => void /** * @deprecated This property is deprecated and will be removed in v4. - * This is for performance reasons. Use the `DocumentTitleContext` instead. + * This is for performance reasons. Use the `DocumentTitleContext` instead + * via the `useDocumentTitle` hook. * @example * ```tsx * import { useDocumentTitle } from '@payloadcms/ui' - * const { title } = useDocumentTitle() + * const { title } = useDocumentTitle() * ``` */ title: string unlockDocument: (docID: number | string, slug: string) => Promise unpublishedVersionCount: number updateDocumentEditor: (docID: number | string, slug: string, user: ClientUser) => Promise + /** + * @deprecated This property is deprecated and will be removed in v4. + * Use `setData` instead. + */ updateSavedDocumentData: (data: Data) => void uploadStatus?: 'failed' | 'idle' | 'uploading' versionCount: number diff --git a/packages/ui/src/providers/DocumentTitle/index.tsx b/packages/ui/src/providers/DocumentTitle/index.tsx index 229421968..1202a9ca4 100644 --- a/packages/ui/src/providers/DocumentTitle/index.tsx +++ b/packages/ui/src/providers/DocumentTitle/index.tsx @@ -19,8 +19,7 @@ export const useDocumentTitle = (): IDocumentTitleContext => use(DocumentTitleCo export const DocumentTitleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { id, collectionSlug, docConfig, globalSlug, initialData, savedDocumentData } = - useDocumentInfo() + const { id, collectionSlug, data, docConfig, globalSlug, initialData } = useDocumentInfo() const { config: { @@ -45,14 +44,14 @@ export const DocumentTitleProvider: React.FC<{ setDocumentTitle( formatDocTitle({ collectionConfig: collectionSlug ? (docConfig as ClientCollectionConfig) : undefined, - data: { ...savedDocumentData, id }, + data: { ...data, id }, dateFormat, fallback: id?.toString(), globalConfig: globalSlug ? (docConfig as ClientGlobalConfig) : undefined, i18n, }), ) - }, [savedDocumentData, dateFormat, i18n, id, collectionSlug, docConfig, globalSlug]) + }, [data, dateFormat, i18n, id, collectionSlug, docConfig, globalSlug]) return {children} } diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index b621fc87d..1e78cf46a 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -65,6 +65,7 @@ export function DefaultEditView({ BeforeFields, collectionSlug, currentEditor, + data, disableActions, disableCreate, disableLeaveWithoutSaving, @@ -86,12 +87,12 @@ export function DefaultEditView({ redirectAfterDelete, redirectAfterDuplicate, redirectAfterRestore, - savedDocumentData, setCurrentEditor, + setData, setDocumentIsLocked, + setLastUpdateTime, unlockDocument, updateDocumentEditor, - updateSavedDocumentData, } = useDocumentInfo() const { @@ -237,7 +238,7 @@ export function DefaultEditView({ setDocumentIsLocked(false) setCurrentEditor(null) } catch (err) { - console.error('Failed to unlock before leave', err) + console.error('Failed to unlock before leave', err) // eslint-disable-line no-console } } } @@ -256,15 +257,17 @@ export function DefaultEditView({ ]) const onSave = useCallback( - async (json): Promise => { + async (json, context?: Record): Promise => { const controller = handleAbortRef(abortOnSaveRef) const document = json?.doc || json?.result + const updatedAt = document?.updatedAt || new Date().toISOString() + reportUpdate({ id, entitySlug, - updatedAt: document?.updatedAt || new Date().toISOString(), + updatedAt, }) // If we're editing the doc of the logged-in user, @@ -273,10 +276,14 @@ export function DefaultEditView({ void refreshCookieAsync() } - incrementVersionCount() + setLastUpdateTime(updatedAt) - if (typeof updateSavedDocumentData === 'function') { - void updateSavedDocumentData(document || {}) + if (context?.incrementVersionCount !== false) { + incrementVersionCount() + } + + if (typeof setData === 'function') { + void setData(document || {}) } if (typeof onSaveFromContext === 'function') { @@ -284,6 +291,7 @@ export function DefaultEditView({ void onSaveFromContext({ ...json, + context, operation, updatedAt: operation === 'update' @@ -306,7 +314,7 @@ export function DefaultEditView({ await getDocPermissions(json) - if ((id || globalSlug) && !autosaveEnabled) { + if (id || globalSlug) { const docPreferences = await getDocPreferences() const { state } = await getFormState({ @@ -341,18 +349,19 @@ export function DefaultEditView({ user, collectionSlug, userSlug, - incrementVersionCount, - updateSavedDocumentData, + setLastUpdateTime, + setData, onSaveFromContext, - redirectAfterCreate, isEditing, depth, + redirectAfterCreate, getDocPermissions, globalSlug, - autosaveEnabled, refreshCookieAsync, + incrementVersionCount, adminRoute, locale, + startRouteTransition, router, resetUploadEdits, getDocPreferences, @@ -362,7 +371,6 @@ export function DefaultEditView({ schemaPathSegments, isLockingEnabled, setDocumentIsLocked, - startRouteTransition, ], ) @@ -549,7 +557,7 @@ export function DefaultEditView({ SaveButton, SaveDraftButton, }} - data={savedDocumentData} + data={data} disableActions={disableActions || isFolderCollection || isTrashed} disableCreate={disableCreate} EditMenuItems={EditMenuItems} @@ -612,14 +620,14 @@ export function DefaultEditView({ className={`${baseClass}__auth`} collectionSlug={collectionConfig.slug} disableLocalStrategy={collectionConfig.auth?.disableLocalStrategy} - email={savedDocumentData?.email} + email={data?.email} loginWithUsername={auth?.loginWithUsername} operation={operation} readOnly={!hasSavePermission} requirePassword={!id} setValidateBeforeSubmit={setValidateBeforeSubmit} useAPIKey={auth.useAPIKey} - username={savedDocumentData?.username} + username={data?.username} verify={auth.verify} /> )} diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 6d7c96401..599c9dec1 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -84,7 +84,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; globals: { menu: Menu; @@ -124,7 +124,7 @@ export interface UserAuthOperations { * via the `definition` "posts". */ export interface Post { - id: number; + id: string; title?: string | null; content?: { root: { @@ -149,7 +149,7 @@ export interface Post { * via the `definition` "media". */ export interface Media { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -193,7 +193,7 @@ export interface Media { * via the `definition` "users". */ export interface User { - id: number; + id: string; updatedAt: string; createdAt: string; email: string; @@ -217,24 +217,24 @@ export interface User { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'posts'; - value: number | Post; + value: string | Post; } | null) | ({ relationTo: 'media'; - value: number | Media; + value: string | Media; } | null) | ({ relationTo: 'users'; - value: number | User; + value: string | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; updatedAt: string; createdAt: string; @@ -244,10 +244,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: number; + id: string; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; key?: string | null; value?: @@ -267,7 +267,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string; @@ -393,7 +393,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "menu". */ export interface Menu { - id: number; + id: string; globalText?: string | null; updatedAt?: string | null; createdAt?: string | null; diff --git a/test/versions/collections/Autosave.ts b/test/versions/collections/Autosave.ts index ff18f7cb1..7350ff2b8 100644 --- a/test/versions/collections/Autosave.ts +++ b/test/versions/collections/Autosave.ts @@ -53,6 +53,14 @@ const AutosavePosts: CollectionConfig = { unique: true, localized: true, }, + { + name: 'computedTitle', + label: 'Computed Title', + type: 'text', + hooks: { + beforeChange: [({ data }) => data?.title], + }, + }, { name: 'description', label: 'Description', diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 13b2428d2..8973c2371 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -1285,6 +1285,44 @@ describe('Versions', () => { // Remove listener page.removeListener('dialog', acceptAlert) }) + + test('- with autosave - applies afterChange hooks to form state after autosave runs', async () => { + const url = new AdminUrlUtil(serverURL, autosaveCollectionSlug) + await page.goto(url.create) + const titleField = page.locator('#field-title') + await titleField.fill('Initial') + await waitForAutoSaveToRunAndComplete(page) + const computedTitleField = page.locator('#field-computedTitle') + await expect(computedTitleField).toHaveValue('Initial') + }) + + test('- with autosave - does not display success toast after autosave complete', async () => { + const url = new AdminUrlUtil(serverURL, autosaveCollectionSlug) + await page.goto(url.create) + const titleField = page.locator('#field-title') + await titleField.fill('Initial') + + let hasDisplayedToast = false + + const startTime = Date.now() + const timeout = 5000 + const interval = 100 + + while (Date.now() - startTime < timeout) { + const isHidden = await page.locator('.payload-toast-item').isHidden() + console.log(`Toast is hidden: ${isHidden}`) + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!isHidden) { + hasDisplayedToast = true + break + } + + await wait(interval) + } + + expect(hasDisplayedToast).toBe(false) + }) }) describe('Globals - publish individual locale', () => { diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index ddfc1db28..d393912cf 100644 --- a/test/versions/payload-types.ts +++ b/test/versions/payload-types.ts @@ -197,6 +197,7 @@ export interface Post { export interface AutosavePost { id: string; title: string; + computedTitle?: string | null; description: string; updatedAt: string; createdAt: string; @@ -366,7 +367,6 @@ export interface Diff { textInNamedTab1InBlock?: string | null; }; textInUnnamedTab2InBlock?: string | null; - textInUnnamedTab2InBlockAccessFalse?: string | null; id?: string | null; blockName?: string | null; blockType: 'TabsBlock'; @@ -469,7 +469,6 @@ export interface Diff { }; textInUnnamedTab2?: string | null; text?: string | null; - textCannotRead?: string | null; textArea?: string | null; upload?: (string | null) | Media; uploadHasMany?: (string | Media)[] | null; @@ -787,6 +786,7 @@ export interface PostsSelect { */ export interface AutosavePostsSelect { title?: T; + computedTitle?: T; description?: T; updatedAt?: T; createdAt?: T; @@ -960,7 +960,6 @@ export interface DiffSelect { textInNamedTab1InBlock?: T; }; textInUnnamedTab2InBlock?: T; - textInUnnamedTab2InBlockAccessFalse?: T; id?: T; blockName?: T; }; @@ -995,7 +994,6 @@ export interface DiffSelect { }; textInUnnamedTab2?: T; text?: T; - textCannotRead?: T; textArea?: T; upload?: T; uploadHasMany?: T;