From 53dfe94b4d183972ad12fdd4e64c1949e54dd89c Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Tue, 5 Aug 2025 08:59:40 -0400 Subject: [PATCH 1/9] savedDocData to data --- docs/admin/react-hooks.mdx | 2 +- packages/ui/src/elements/Upload/index.tsx | 32 ++++++++----------- .../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 | 8 ++--- 6 files changed, 49 insertions(+), 44 deletions(-) diff --git a/docs/admin/react-hooks.mdx b/docs/admin/react-hooks.mdx index 5640f185caf..2d021daf20a 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/Upload/index.tsx b/packages/ui/src/elements/Upload/index.tsx index e9f0c927199..475a9efd286 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/providers/DocumentInfo/index.tsx b/packages/ui/src/providers/DocumentInfo/index.tsx index 0fdb4d93241..12ad4646481 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 7f9233e3654..4d8d6404c20 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 22942196818..1202a9ca472 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 b621fc87d9f..18c08ca6075 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,7 +87,6 @@ export function DefaultEditView({ redirectAfterDelete, redirectAfterDuplicate, redirectAfterRestore, - savedDocumentData, setCurrentEditor, setDocumentIsLocked, unlockDocument, @@ -549,7 +549,7 @@ export function DefaultEditView({ SaveButton, SaveDraftButton, }} - data={savedDocumentData} + data={data} disableActions={disableActions || isFolderCollection || isTrashed} disableCreate={disableCreate} EditMenuItems={EditMenuItems} @@ -612,14 +612,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} /> )} From a5997a83b222ab1b14a8b08f43b17bbf26286b08 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 8 Aug 2025 14:41:02 -0400 Subject: [PATCH 2/9] poc: autosave through form submit --- packages/ui/src/elements/Autosave/index.tsx | 156 ++++---------------- packages/ui/src/forms/Form/index.tsx | 11 +- packages/ui/src/forms/Form/types.ts | 1 + packages/ui/src/views/Edit/index.tsx | 27 +++- tsconfig.base.json | 114 ++++---------- 5 files changed, 84 insertions(+), 225 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 2215f7e659d..3c8fcc331d0 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' @@ -46,21 +43,9 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }, } = useConfig() - const { - docConfig, - incrementVersionCount, - lastUpdateTime, - mostRecentVersionIsAutosaved, - setLastUpdateTime, - setMostRecentVersionIsAutosaved, - setUnpublishedVersionCount, - updateSavedDocumentData, - } = useDocumentInfo() - - const { onSave: onSaveFromDocumentDrawer } = useDocumentDrawerContext() + const { docConfig, lastUpdateTime } = useDocumentInfo() - const { reportUpdate } = useDocumentEvents() - const { dispatchFields, isValid, setBackgroundProcessing, setIsValid } = useForm() + const { isValid, setBackgroundProcessing, submit } = useForm() const [formState] = useAllFormFields() const modified = useFormModified() @@ -151,117 +136,32 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) method = 'POST' } - if (url) { - if (modifiedRef.current) { - const { data, valid } = reduceFieldsToValuesWithValidation(formStateRef.current, true) - - data._status = 'draft' - - const skipSubmission = - submitted && !valid && 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 (modifiedRef.current && url) { + const { data, valid } = reduceFieldsToValuesWithValidation(formStateRef.current, true) + + data._status = 'draft' + + const skipSubmission = + submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate + + if (!skipSubmission) { + await submit({ + action: url, + method, + overrides: { + _status: 'draft', + }, + // disableFormWhileProcessing: false + // setProcessing: false, + skipValidation: versionsConfig?.drafts && !versionsConfig?.drafts?.validate, + }) + + 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/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 79e12b29ff9..ea9d2327036 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -18,7 +18,7 @@ import type { Context as FormContextType, FormProps, GetDataByPath, - SubmitOptions, + Submit, } from './types.js' import { FieldErrorsToast } from '../../elements/Toasts/fieldErrors.js' @@ -199,8 +199,8 @@ 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, method: methodToUse = method, @@ -217,6 +217,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 @@ -290,6 +291,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,6 +368,7 @@ 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) @@ -379,6 +382,7 @@ export const Form: React.FC = (props) => { }) } } + setSubmitted(false) setProcessing(false) @@ -392,6 +396,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 diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index 9cfd1ef6441..8cfc91130d9 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -76,6 +76,7 @@ export type SubmitOptions = { } export type DispatchFields = React.Dispatch + export type Submit = ( options?: SubmitOptions, e?: React.FormEvent, diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 18c08ca6075..65332228bd7 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -88,10 +88,13 @@ export function DefaultEditView({ redirectAfterDuplicate, redirectAfterRestore, setCurrentEditor, + setData, setDocumentIsLocked, + setLastUpdateTime, + setMostRecentVersionIsAutosaved, + setUnpublishedVersionCount, unlockDocument, updateDocumentEditor, - updateSavedDocumentData, } = useDocumentInfo() const { @@ -237,7 +240,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 } } } @@ -261,10 +264,12 @@ export function DefaultEditView({ 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 +278,16 @@ export function DefaultEditView({ void refreshCookieAsync() } - incrementVersionCount() + setLastUpdateTime(updatedAt) + + if (!setMostRecentVersionIsAutosaved) { + incrementVersionCount() + setMostRecentVersionIsAutosaved(true) + setUnpublishedVersionCount((prev) => prev + 1) + } - if (typeof updateSavedDocumentData === 'function') { - void updateSavedDocumentData(document || {}) + if (typeof setData === 'function') { + void setData(document || {}) } if (typeof onSaveFromContext === 'function') { @@ -342,7 +353,7 @@ export function DefaultEditView({ collectionSlug, userSlug, incrementVersionCount, - updateSavedDocumentData, + setData, onSaveFromContext, redirectAfterCreate, isEditing, @@ -363,6 +374,8 @@ export function DefaultEditView({ isLockingEnabled, setDocumentIsLocked, startRouteTransition, + setUnpublishedVersionCount, + setMostRecentVersionIsAutosaved, ], ) diff --git a/tsconfig.base.json b/tsconfig.base.json index ab391009848..0898ad390f3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,15 +21,8 @@ "skipLibCheck": true, "emitDeclarationOnly": true, "sourceMap": true, - "lib": [ - "DOM", - "DOM.Iterable", - "ES2022" - ], - "types": [ - "node", - "jest" - ], + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "jest"], "incremental": true, "isolatedModules": true, "plugins": [ @@ -38,72 +31,36 @@ } ], "paths": { - "@payload-config": [ - "./test/joins/config.ts" - ], - "@payloadcms/admin-bar": [ - "./packages/admin-bar/src" - ], - "@payloadcms/live-preview": [ - "./packages/live-preview/src" - ], - "@payloadcms/live-preview-react": [ - "./packages/live-preview-react/src/index.ts" - ], - "@payloadcms/live-preview-vue": [ - "./packages/live-preview-vue/src/index.ts" - ], - "@payloadcms/ui": [ - "./packages/ui/src/exports/client/index.ts" - ], - "@payloadcms/ui/shared": [ - "./packages/ui/src/exports/shared/index.ts" - ], - "@payloadcms/ui/rsc": [ - "./packages/ui/src/exports/rsc/index.ts" - ], - "@payloadcms/ui/scss": [ - "./packages/ui/src/scss.scss" - ], - "@payloadcms/ui/scss/app.scss": [ - "./packages/ui/src/scss/app.scss" - ], - "@payloadcms/next/*": [ - "./packages/next/src/exports/*.ts" - ], + "@payload-config": ["./test/_community/config.ts"], + "@payloadcms/admin-bar": ["./packages/admin-bar/src"], + "@payloadcms/live-preview": ["./packages/live-preview/src"], + "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], + "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], + "@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"], + "@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"], + "@payloadcms/ui/rsc": ["./packages/ui/src/exports/rsc/index.ts"], + "@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"], + "@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"], + "@payloadcms/next/*": ["./packages/next/src/exports/*.ts"], "@payloadcms/richtext-lexical/client": [ "./packages/richtext-lexical/src/exports/client/index.ts" ], - "@payloadcms/richtext-lexical/rsc": [ - "./packages/richtext-lexical/src/exports/server/rsc.ts" - ], - "@payloadcms/richtext-slate/rsc": [ - "./packages/richtext-slate/src/exports/server/rsc.ts" - ], + "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"], + "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"], "@payloadcms/richtext-slate/client": [ "./packages/richtext-slate/src/exports/client/index.ts" ], - "@payloadcms/plugin-seo/client": [ - "./packages/plugin-seo/src/exports/client.ts" - ], - "@payloadcms/plugin-sentry/client": [ - "./packages/plugin-sentry/src/exports/client.ts" - ], - "@payloadcms/plugin-stripe/client": [ - "./packages/plugin-stripe/src/exports/client.ts" - ], - "@payloadcms/plugin-search/client": [ - "./packages/plugin-search/src/exports/client.ts" - ], + "@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"], + "@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"], + "@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"], + "@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"], "@payloadcms/plugin-form-builder/client": [ "./packages/plugin-form-builder/src/exports/client.ts" ], "@payloadcms/plugin-import-export/rsc": [ "./packages/plugin-import-export/src/exports/rsc.ts" ], - "@payloadcms/plugin-multi-tenant/rsc": [ - "./packages/plugin-multi-tenant/src/exports/rsc.ts" - ], + "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], "@payloadcms/plugin-multi-tenant/utilities": [ "./packages/plugin-multi-tenant/src/exports/utilities.ts" ], @@ -113,42 +70,25 @@ "@payloadcms/plugin-multi-tenant/client": [ "./packages/plugin-multi-tenant/src/exports/client.ts" ], - "@payloadcms/plugin-multi-tenant": [ - "./packages/plugin-multi-tenant/src/index.ts" - ], + "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], "@payloadcms/plugin-multi-tenant/translations/languages/all": [ "./packages/plugin-multi-tenant/src/translations/index.ts" ], "@payloadcms/plugin-multi-tenant/translations/languages/*": [ "./packages/plugin-multi-tenant/src/translations/languages/*.ts" ], - "@payloadcms/next": [ - "./packages/next/src/exports/*" - ], - "@payloadcms/storage-azure/client": [ - "./packages/storage-azure/src/exports/client.ts" - ], - "@payloadcms/storage-s3/client": [ - "./packages/storage-s3/src/exports/client.ts" - ], + "@payloadcms/next": ["./packages/next/src/exports/*"], + "@payloadcms/storage-azure/client": ["./packages/storage-azure/src/exports/client.ts"], + "@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], "@payloadcms/storage-vercel-blob/client": [ "./packages/storage-vercel-blob/src/exports/client.ts" ], - "@payloadcms/storage-gcs/client": [ - "./packages/storage-gcs/src/exports/client.ts" - ], + "@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"], "@payloadcms/storage-uploadthing/client": [ "./packages/storage-uploadthing/src/exports/client.ts" ] } }, - "include": [ - "${configDir}/src" - ], - "exclude": [ - "${configDir}/dist", - "${configDir}/build", - "${configDir}/temp", - "**/*.spec.ts" - ] + "include": ["${configDir}/src"], + "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"] } From 33c06529fee16419d61c7758246f27cd093b9e53 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 8 Aug 2025 15:36:52 -0400 Subject: [PATCH 3/9] add test --- test/versions/collections/Autosave.ts | 8 ++++++++ test/versions/e2e.spec.ts | 9 +++++++++ test/versions/payload-types.ts | 2 ++ 3 files changed, 19 insertions(+) diff --git a/test/versions/collections/Autosave.ts b/test/versions/collections/Autosave.ts index ff18f7cb1bc..7350ff2b876 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 3bbf8d514dd..824ac9dcd9e 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -1285,6 +1285,15 @@ describe('Versions', () => { // Remove listener page.removeListener('dialog', acceptAlert) }) + + test('- with autosave - should apply hooks to form state after autosave runs', async () => { + await page.goto(autosaveURL.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') + }) }) describe('Globals - publish individual locale', () => { diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index 3172effc093..d393912cf4a 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; @@ -785,6 +786,7 @@ export interface PostsSelect { */ export interface AutosavePostsSelect { title?: T; + computedTitle?: T; description?: T; updatedAt?: T; createdAt?: T; From 82b01866a3142e52fb534a029cde7e2815e47bc2 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 8 Aug 2025 16:22:43 -0400 Subject: [PATCH 4/9] get form state on autosave event --- packages/ui/src/elements/Autosave/index.tsx | 47 ++++---- packages/ui/src/views/Edit/index.tsx | 17 +-- test/versions/e2e.spec.ts | 3 +- tsconfig.base.json | 114 +++++++++++++++----- 4 files changed, 117 insertions(+), 64 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 3c8fcc331d0..4e5c598d995 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -20,7 +20,6 @@ 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 { LeaveWithoutSaving } from '../LeaveWithoutSaving/index.js' import './index.scss' @@ -136,33 +135,25 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) method = 'POST' } - if (modifiedRef.current && url) { - const { data, valid } = reduceFieldsToValuesWithValidation(formStateRef.current, true) - - data._status = 'draft' - - const skipSubmission = - submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate - - if (!skipSubmission) { - await submit({ - action: url, - method, - overrides: { - _status: 'draft', - }, - // disableFormWhileProcessing: false - // setProcessing: false, - skipValidation: versionsConfig?.drafts && !versionsConfig?.drafts?.validate, - }) - - 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() - } + if (!submitted && modifiedRef.current && url) { + await submit({ + action: url, + method, + overrides: { + _status: 'draft', + }, + // disableFormWhileProcessing: false + // setProcessing: false, + // showToast: false, + skipValidation: versionsConfig?.drafts && !versionsConfig?.drafts?.validate, + }) + + 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/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 65332228bd7..c5a8701c967 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -280,7 +280,7 @@ export function DefaultEditView({ setLastUpdateTime(updatedAt) - if (!setMostRecentVersionIsAutosaved) { + if (!setMostRecentVersionIsAutosaved && autosaveEnabled) { incrementVersionCount() setMostRecentVersionIsAutosaved(true) setUnpublishedVersionCount((prev) => prev + 1) @@ -317,7 +317,7 @@ export function DefaultEditView({ await getDocPermissions(json) - if ((id || globalSlug) && !autosaveEnabled) { + if (id || globalSlug) { const docPreferences = await getDocPreferences() const { state } = await getFormState({ @@ -352,18 +352,22 @@ export function DefaultEditView({ user, collectionSlug, userSlug, - incrementVersionCount, + setLastUpdateTime, + setMostRecentVersionIsAutosaved, + autosaveEnabled, setData, onSaveFromContext, - redirectAfterCreate, isEditing, depth, + redirectAfterCreate, getDocPermissions, globalSlug, - autosaveEnabled, refreshCookieAsync, + incrementVersionCount, + setUnpublishedVersionCount, adminRoute, locale, + startRouteTransition, router, resetUploadEdits, getDocPreferences, @@ -373,9 +377,6 @@ export function DefaultEditView({ schemaPathSegments, isLockingEnabled, setDocumentIsLocked, - startRouteTransition, - setUnpublishedVersionCount, - setMostRecentVersionIsAutosaved, ], ) diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 824ac9dcd9e..6b41b29ea62 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -1287,7 +1287,8 @@ describe('Versions', () => { }) test('- with autosave - should apply hooks to form state after autosave runs', async () => { - await page.goto(autosaveURL.create) + const url = new AdminUrlUtil(serverURL, autosaveCollectionSlug) + await page.goto(url.create) const titleField = page.locator('#field-title') await titleField.fill('Initial') await waitForAutoSaveToRunAndComplete(page) diff --git a/tsconfig.base.json b/tsconfig.base.json index 0898ad390f3..185d7d36268 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,8 +21,15 @@ "skipLibCheck": true, "emitDeclarationOnly": true, "sourceMap": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "jest"], + "lib": [ + "DOM", + "DOM.Iterable", + "ES2022" + ], + "types": [ + "node", + "jest" + ], "incremental": true, "isolatedModules": true, "plugins": [ @@ -31,36 +38,72 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], - "@payloadcms/admin-bar": ["./packages/admin-bar/src"], - "@payloadcms/live-preview": ["./packages/live-preview/src"], - "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], - "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], - "@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"], - "@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"], - "@payloadcms/ui/rsc": ["./packages/ui/src/exports/rsc/index.ts"], - "@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"], - "@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"], - "@payloadcms/next/*": ["./packages/next/src/exports/*.ts"], + "@payload-config": [ + "./test/versions/config.ts" + ], + "@payloadcms/admin-bar": [ + "./packages/admin-bar/src" + ], + "@payloadcms/live-preview": [ + "./packages/live-preview/src" + ], + "@payloadcms/live-preview-react": [ + "./packages/live-preview-react/src/index.ts" + ], + "@payloadcms/live-preview-vue": [ + "./packages/live-preview-vue/src/index.ts" + ], + "@payloadcms/ui": [ + "./packages/ui/src/exports/client/index.ts" + ], + "@payloadcms/ui/shared": [ + "./packages/ui/src/exports/shared/index.ts" + ], + "@payloadcms/ui/rsc": [ + "./packages/ui/src/exports/rsc/index.ts" + ], + "@payloadcms/ui/scss": [ + "./packages/ui/src/scss.scss" + ], + "@payloadcms/ui/scss/app.scss": [ + "./packages/ui/src/scss/app.scss" + ], + "@payloadcms/next/*": [ + "./packages/next/src/exports/*.ts" + ], "@payloadcms/richtext-lexical/client": [ "./packages/richtext-lexical/src/exports/client/index.ts" ], - "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"], - "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"], + "@payloadcms/richtext-lexical/rsc": [ + "./packages/richtext-lexical/src/exports/server/rsc.ts" + ], + "@payloadcms/richtext-slate/rsc": [ + "./packages/richtext-slate/src/exports/server/rsc.ts" + ], "@payloadcms/richtext-slate/client": [ "./packages/richtext-slate/src/exports/client/index.ts" ], - "@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"], - "@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"], - "@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"], - "@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"], + "@payloadcms/plugin-seo/client": [ + "./packages/plugin-seo/src/exports/client.ts" + ], + "@payloadcms/plugin-sentry/client": [ + "./packages/plugin-sentry/src/exports/client.ts" + ], + "@payloadcms/plugin-stripe/client": [ + "./packages/plugin-stripe/src/exports/client.ts" + ], + "@payloadcms/plugin-search/client": [ + "./packages/plugin-search/src/exports/client.ts" + ], "@payloadcms/plugin-form-builder/client": [ "./packages/plugin-form-builder/src/exports/client.ts" ], "@payloadcms/plugin-import-export/rsc": [ "./packages/plugin-import-export/src/exports/rsc.ts" ], - "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], + "@payloadcms/plugin-multi-tenant/rsc": [ + "./packages/plugin-multi-tenant/src/exports/rsc.ts" + ], "@payloadcms/plugin-multi-tenant/utilities": [ "./packages/plugin-multi-tenant/src/exports/utilities.ts" ], @@ -70,25 +113,42 @@ "@payloadcms/plugin-multi-tenant/client": [ "./packages/plugin-multi-tenant/src/exports/client.ts" ], - "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], + "@payloadcms/plugin-multi-tenant": [ + "./packages/plugin-multi-tenant/src/index.ts" + ], "@payloadcms/plugin-multi-tenant/translations/languages/all": [ "./packages/plugin-multi-tenant/src/translations/index.ts" ], "@payloadcms/plugin-multi-tenant/translations/languages/*": [ "./packages/plugin-multi-tenant/src/translations/languages/*.ts" ], - "@payloadcms/next": ["./packages/next/src/exports/*"], - "@payloadcms/storage-azure/client": ["./packages/storage-azure/src/exports/client.ts"], - "@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], + "@payloadcms/next": [ + "./packages/next/src/exports/*" + ], + "@payloadcms/storage-azure/client": [ + "./packages/storage-azure/src/exports/client.ts" + ], + "@payloadcms/storage-s3/client": [ + "./packages/storage-s3/src/exports/client.ts" + ], "@payloadcms/storage-vercel-blob/client": [ "./packages/storage-vercel-blob/src/exports/client.ts" ], - "@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"], + "@payloadcms/storage-gcs/client": [ + "./packages/storage-gcs/src/exports/client.ts" + ], "@payloadcms/storage-uploadthing/client": [ "./packages/storage-uploadthing/src/exports/client.ts" ] } }, - "include": ["${configDir}/src"], - "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"] + "include": [ + "${configDir}/src" + ], + "exclude": [ + "${configDir}/dist", + "${configDir}/build", + "${configDir}/temp", + "**/*.spec.ts" + ] } From 666cedda5bbaf3b98b8d465230fd69dd0675ed47 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 8 Aug 2025 16:46:34 -0400 Subject: [PATCH 5/9] disable success msg on autosave --- packages/ui/src/elements/Autosave/index.tsx | 2 +- packages/ui/src/forms/Form/index.tsx | 7 +++-- packages/ui/src/forms/Form/types.ts | 1 + test/versions/e2e.spec.ts | 30 ++++++++++++++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 4e5c598d995..e2c689d7b74 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -144,7 +144,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }, // disableFormWhileProcessing: false // setProcessing: false, - // showToast: false, + disableSuccessStatus: true, skipValidation: versionsConfig?.drafts && !versionsConfig?.drafts?.validate, }) diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index ea9d2327036..3914e56ee70 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -203,11 +203,14 @@ export const Form: React.FC = (props) => { async (options, e) => { const { action: actionArg = action, + disableSuccessStatus: disableSuccessStatusFromArgs, method: methodToUse = method, overrides: overridesFromArgs = {}, skipValidation, } = options + const disableToast = disableSuccessStatusFromArgs ?? disableSuccessStatus + if (disabled) { if (e) { e.preventDefault() @@ -226,7 +229,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) @@ -388,7 +391,7 @@ export const Form: React.FC = (props) => { if (redirect) { startRouteTransition(() => router.push(redirect)) - } else if (!disableSuccessStatus) { + } else if (!disableToast) { successToast(json.message || t('general:submissionSuccessful')) } } else { diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index 8cfc91130d9..b5092f88b20 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -70,6 +70,7 @@ export type FormProps = { export type SubmitOptions = { action?: string + disableSuccessStatus?: boolean method?: string overrides?: ((formState) => FormData) | Record skipValidation?: boolean diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 6b41b29ea62..0965395570f 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -1286,7 +1286,7 @@ describe('Versions', () => { page.removeListener('dialog', acceptAlert) }) - test('- with autosave - should apply hooks to form state after autosave runs', async () => { + 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') @@ -1295,6 +1295,34 @@ describe('Versions', () => { 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', () => { From 32e229017549eb9e4c4af6cfb1378eea5a656bd3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 8 Aug 2025 17:29:17 -0400 Subject: [PATCH 6/9] do not disable form during autosave --- packages/ui/src/elements/Autosave/index.tsx | 2 +- packages/ui/src/forms/Form/index.tsx | 7 +++++-- packages/ui/src/forms/Form/types.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index e2c689d7b74..e81ee2db2ba 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -138,11 +138,11 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) if (!submitted && modifiedRef.current && url) { await submit({ action: url, + disableFormWhileProcessing: false, method, overrides: { _status: 'draft', }, - // disableFormWhileProcessing: false // setProcessing: false, disableSuccessStatus: true, skipValidation: versionsConfig?.drafts && !versionsConfig?.drafts?.validate, diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 3914e56ee70..da70949a0b4 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -203,6 +203,7 @@ export const Form: React.FC = (props) => { async (options, e) => { const { action: actionArg = action, + disableFormWhileProcessing = true, disableSuccessStatus: disableSuccessStatusFromArgs, method: methodToUse = method, overrides: overridesFromArgs = {}, @@ -251,8 +252,10 @@ export const Form: React.FC = (props) => { e.preventDefault() } - setProcessing(true) - setDisabled(true) + if (disableFormWhileProcessing) { + setProcessing(true) + setDisabled(true) + } if (waitForAutocomplete) { await wait(100) diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index b5092f88b20..e6fc58fd9cd 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -70,9 +70,22 @@ export type FormProps = { export type SubmitOptions = { action?: string + /** + * 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 } From dd3f0666e324815a2fd6836c04dc49ef3e84e1f8 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 8 Aug 2025 23:32:20 -0400 Subject: [PATCH 7/9] options fallback --- packages/ui/src/forms/Form/index.tsx | 3 ++- test/_community/payload-types.ts | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index da70949a0b4..401ea2f4191 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -19,6 +19,7 @@ import type { FormProps, GetDataByPath, Submit, + SubmitOptions, } from './types.js' import { FieldErrorsToast } from '../../elements/Toasts/fieldErrors.js' @@ -208,7 +209,7 @@ export const Form: React.FC = (props) => { method: methodToUse = method, overrides: overridesFromArgs = {}, skipValidation, - } = options + } = options || ({} as SubmitOptions) const disableToast = disableSuccessStatusFromArgs ?? disableSuccessStatus diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 6d7c9640105..599c9dec1d7 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; From a0da55b30210008d08af46e0bdfe8e232c7767a6 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 11 Aug 2025 08:32:11 -0400 Subject: [PATCH 8/9] fix increment version count --- packages/ui/src/views/Edit/index.tsx | 11 ++++++++--- test/versions/payload-types.ts | 4 ---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index c5a8701c967..cad82c522e5 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -83,6 +83,7 @@ export function DefaultEditView({ isInitializing, isTrashed, lastUpdateTime, + mostRecentVersionIsAutosaved, redirectAfterCreate, redirectAfterDelete, redirectAfterDuplicate, @@ -280,10 +281,14 @@ export function DefaultEditView({ setLastUpdateTime(updatedAt) - if (!setMostRecentVersionIsAutosaved && autosaveEnabled) { + if (autosaveEnabled) { + if (!mostRecentVersionIsAutosaved) { + incrementVersionCount() + setMostRecentVersionIsAutosaved(true) + setUnpublishedVersionCount((prev) => prev + 1) + } + } else { incrementVersionCount() - setMostRecentVersionIsAutosaved(true) - setUnpublishedVersionCount((prev) => prev + 1) } if (typeof setData === 'function') { diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index 47459f7b680..d393912cf4a 100644 --- a/test/versions/payload-types.ts +++ b/test/versions/payload-types.ts @@ -367,7 +367,6 @@ export interface Diff { textInNamedTab1InBlock?: string | null; }; textInUnnamedTab2InBlock?: string | null; - textInUnnamedTab2InBlockAccessFalse?: string | null; id?: string | null; blockName?: string | null; blockType: 'TabsBlock'; @@ -470,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; @@ -962,7 +960,6 @@ export interface DiffSelect { textInNamedTab1InBlock?: T; }; textInUnnamedTab2InBlock?: T; - textInUnnamedTab2InBlockAccessFalse?: T; id?: T; blockName?: T; }; @@ -997,7 +994,6 @@ export interface DiffSelect { }; textInUnnamedTab2?: T; text?: T; - textCannotRead?: T; textArea?: T; upload?: T; uploadHasMany?: T; From 65d0f7070afe0bdb80922ebc761e915218c2305c Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 11 Aug 2025 09:30:16 -0400 Subject: [PATCH 9/9] isolates autosave version handling --- packages/ui/src/elements/Autosave/index.tsx | 31 ++++++++++++++++--- .../src/elements/DocumentDrawer/Provider.tsx | 5 +++ packages/ui/src/forms/Form/index.tsx | 5 ++- packages/ui/src/forms/Form/types.ts | 13 ++++++-- packages/ui/src/views/Edit/index.tsx | 17 ++-------- 5 files changed, 49 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index e81ee2db2ba..f341efa21e8 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -20,6 +20,7 @@ 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 { LeaveWithoutSaving } from '../LeaveWithoutSaving/index.js' import './index.scss' @@ -42,7 +43,14 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }, } = useConfig() - const { docConfig, lastUpdateTime } = useDocumentInfo() + const { + docConfig, + incrementVersionCount, + lastUpdateTime, + mostRecentVersionIsAutosaved, + setMostRecentVersionIsAutosaved, + setUnpublishedVersionCount, + } = useDocumentInfo() const { isValid, setBackgroundProcessing, submit } = useForm() @@ -135,19 +143,32 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) method = 'POST' } - if (!submitted && modifiedRef.current && url) { - await submit({ + const { valid } = reduceFieldsToValuesWithValidation(formStateRef.current, true) + + 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', }, - // setProcessing: false, - disableSuccessStatus: true, skipValidation: versionsConfig?.drafts && !versionsConfig?.drafts?.validate, }) + 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 diff --git a/packages/ui/src/elements/DocumentDrawer/Provider.tsx b/packages/ui/src/elements/DocumentDrawer/Provider.tsx index 6b6c8c2b513..1bc46690535 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/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 401ea2f4191..ddb1cb115de 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -204,6 +204,7 @@ export const Form: React.FC = (props) => { async (options, e) => { const { action: actionArg = action, + context, disableFormWhileProcessing = true, disableSuccessStatus: disableSuccessStatusFromArgs, method: methodToUse = method, @@ -378,7 +379,7 @@ export const Form: React.FC = (props) => { if (res.status < 400) { if (typeof onSuccess === 'function') { - const newFormState = await onSuccess(json) + const newFormState = await onSuccess(json, context) if (newFormState) { dispatchFields({ @@ -455,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 e6fc58fd9cd..a10350506ad 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,6 +70,11 @@ 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 @@ -94,7 +99,11 @@ 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/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index cad82c522e5..1e78cf46ac6 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -83,7 +83,6 @@ export function DefaultEditView({ isInitializing, isTrashed, lastUpdateTime, - mostRecentVersionIsAutosaved, redirectAfterCreate, redirectAfterDelete, redirectAfterDuplicate, @@ -92,8 +91,6 @@ export function DefaultEditView({ setData, setDocumentIsLocked, setLastUpdateTime, - setMostRecentVersionIsAutosaved, - setUnpublishedVersionCount, unlockDocument, updateDocumentEditor, } = useDocumentInfo() @@ -260,7 +257,7 @@ export function DefaultEditView({ ]) const onSave = useCallback( - async (json): Promise => { + async (json, context?: Record): Promise => { const controller = handleAbortRef(abortOnSaveRef) const document = json?.doc || json?.result @@ -281,13 +278,7 @@ export function DefaultEditView({ setLastUpdateTime(updatedAt) - if (autosaveEnabled) { - if (!mostRecentVersionIsAutosaved) { - incrementVersionCount() - setMostRecentVersionIsAutosaved(true) - setUnpublishedVersionCount((prev) => prev + 1) - } - } else { + if (context?.incrementVersionCount !== false) { incrementVersionCount() } @@ -300,6 +291,7 @@ export function DefaultEditView({ void onSaveFromContext({ ...json, + context, operation, updatedAt: operation === 'update' @@ -358,8 +350,6 @@ export function DefaultEditView({ collectionSlug, userSlug, setLastUpdateTime, - setMostRecentVersionIsAutosaved, - autosaveEnabled, setData, onSaveFromContext, isEditing, @@ -369,7 +359,6 @@ export function DefaultEditView({ globalSlug, refreshCookieAsync, incrementVersionCount, - setUnpublishedVersionCount, adminRoute, locale, startRouteTransition,