fix(ui): disable publish button if form is autosaving (#11343)

Fixes https://github.com/payloadcms/payload/issues/6648

This PR introduces a new `useFormBackgroundProcessing` hook and a corresponding `setBackgroundProcessing` function in the `useForm` hook.

Unlike `useFormProcessing` / `setProcessing`, which mark the entire form as read-only, this new approach only disables the Publish button during autosaving, keeping form fields editable for a better user experience.

I named it `backgroundProcessing` because it should run behind the scenes without disrupting the user. You could argue that it is a bit more generic than something like `isAutosaving`, but it signals intent: Background = do not disrupt the user.
This commit is contained in:
Alessio Gravili
2025-02-27 10:28:08 -07:00
committed by GitHub
parent bdf0113b2f
commit 7118b6418f
7 changed files with 60 additions and 9 deletions

View File

@@ -11,6 +11,7 @@ import {
useAllFormFields, useAllFormFields,
useForm, useForm,
useFormModified, useFormModified,
useFormProcessing,
useFormSubmitted, useFormSubmitted,
} from '../../forms/Form/context.js' } from '../../forms/Form/context.js'
import { useDebounce } from '../../hooks/useDebounce.js' import { useDebounce } from '../../hooks/useDebounce.js'
@@ -57,7 +58,8 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
const isProcessingRef = useRef(false) const isProcessingRef = useRef(false)
const { reportUpdate } = useDocumentEvents() const { reportUpdate } = useDocumentEvents()
const { dispatchFields, isValid, setIsValid, setSubmitted } = useForm() const { dispatchFields, isValid, setBackgroundProcessing, setIsValid, setSubmitted } = useForm()
const isFormProcessing = useFormProcessing()
const [fields] = useAllFormFields() const [fields] = useAllFormFields()
const modified = useFormModified() const modified = useFormModified()
@@ -108,6 +110,13 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
return return
} }
// Do not autosave if the form is already processing (e.g. if the user clicked the publish button
// right before this autosave runs), as parallel updates could cause conflicts
if (isFormProcessing) {
queueRef.current = []
return
}
if (!isValidRef.current) { if (!isValidRef.current) {
// Clear queue so we don't end up in an infinite loop // Clear queue so we don't end up in an infinite loop
queueRef.current = [] queueRef.current = []
@@ -120,15 +129,18 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
const latestAction = queueRef.current[queueRef.current.length - 1] const latestAction = queueRef.current[queueRef.current.length - 1]
queueRef.current = [] queueRef.current = []
setBackgroundProcessing(true)
try { try {
await latestAction() await latestAction()
} finally { } finally {
isProcessingRef.current = false isProcessingRef.current = false
setBackgroundProcessing(false)
if (queueRef.current.length > 0) { if (queueRef.current.length > 0) {
await processQueue() await processQueue()
} }
} }
}, []) setBackgroundProcessing(false)
}, [isFormProcessing, setBackgroundProcessing])
const autosaveTimeoutRef = useRef<NodeJS.Timeout | null>(null) const autosaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)

View File

@@ -192,6 +192,7 @@ export {
useAllFormFields, useAllFormFields,
useDocumentForm, useDocumentForm,
useForm, useForm,
useFormBackgroundProcessing,
useFormFields, useFormFields,
useFormInitializing, useFormInitializing,
useFormModified, useFormModified,

View File

@@ -15,6 +15,11 @@ const DocumentFormContext = createContext({} as Context)
const FormWatchContext = createContext({} as Context) const FormWatchContext = createContext({} as Context)
const SubmittedContext = createContext(false) const SubmittedContext = createContext(false)
const ProcessingContext = createContext(false) const ProcessingContext = createContext(false)
/**
* If the form has started processing in the background (e.g.
* if autosave is running), this will be true.
*/
const BackgroundProcessingContext = createContext(false)
const ModifiedContext = createContext(false) const ModifiedContext = createContext(false)
const InitializingContext = createContext(false) const InitializingContext = createContext(false)
const FormFieldsContext = createSelectorContext<FormFieldsContextType>([{}, () => null]) const FormFieldsContext = createSelectorContext<FormFieldsContextType>([{}, () => null])
@@ -36,6 +41,11 @@ const useDocumentForm = (): Context => useContext(DocumentFormContext)
const useWatchForm = (): Context => useContext(FormWatchContext) const useWatchForm = (): Context => useContext(FormWatchContext)
const useFormSubmitted = (): boolean => useContext(SubmittedContext) const useFormSubmitted = (): boolean => useContext(SubmittedContext)
const useFormProcessing = (): boolean => useContext(ProcessingContext) const useFormProcessing = (): boolean => useContext(ProcessingContext)
/**
* If the form has started processing in the background (e.g.
* if autosave is running), this will be true.
*/
const useFormBackgroundProcessing = (): boolean => useContext(BackgroundProcessingContext)
const useFormModified = (): boolean => useContext(ModifiedContext) const useFormModified = (): boolean => useContext(ModifiedContext)
const useFormInitializing = (): boolean => useContext(InitializingContext) const useFormInitializing = (): boolean => useContext(InitializingContext)
@@ -56,6 +66,7 @@ const useFormFields = <Value = unknown>(
const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext) const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext)
export { export {
BackgroundProcessingContext,
DocumentFormContext, DocumentFormContext,
FormContext, FormContext,
FormFieldsContext, FormFieldsContext,
@@ -67,6 +78,7 @@ export {
useAllFormFields, useAllFormFields,
useDocumentForm, useDocumentForm,
useForm, useForm,
useFormBackgroundProcessing,
useFormFields, useFormFields,
useFormInitializing, useFormInitializing,
useFormModified, useFormModified,

View File

@@ -36,6 +36,7 @@ import { useUploadHandlers } from '../../providers/UploadHandlers/index.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js' import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { requests } from '../../utilities/api.js' import { requests } from '../../utilities/api.js'
import { import {
BackgroundProcessingContext,
DocumentFormContext, DocumentFormContext,
FormContext, FormContext,
FormFieldsContext, FormFieldsContext,
@@ -105,6 +106,8 @@ export const Form: React.FC<FormProps> = (props) => {
const [isValid, setIsValid] = useState(true) const [isValid, setIsValid] = useState(true)
const [initializing, setInitializing] = useState(initializingFromProps) const [initializing, setInitializing] = useState(initializingFromProps)
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false)
const [backgroundProcessing, setBackgroundProcessing] = useState(false)
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const contextRef = useRef({} as FormContextType) const contextRef = useRef({} as FormContextType)
@@ -653,6 +656,8 @@ export const Form: React.FC<FormProps> = (props) => {
contextRef.current.createFormData = createFormData contextRef.current.createFormData = createFormData
contextRef.current.setModified = setModified contextRef.current.setModified = setModified
contextRef.current.setProcessing = setProcessing contextRef.current.setProcessing = setProcessing
contextRef.current.setBackgroundProcessing = setBackgroundProcessing
contextRef.current.setSubmitted = setSubmitted contextRef.current.setSubmitted = setSubmitted
contextRef.current.setIsValid = setIsValid contextRef.current.setIsValid = setIsValid
contextRef.current.disabled = disabled contextRef.current.disabled = disabled
@@ -798,11 +803,13 @@ export const Form: React.FC<FormProps> = (props) => {
<SubmittedContext.Provider value={submitted}> <SubmittedContext.Provider value={submitted}>
<InitializingContext.Provider value={!isMounted || (isMounted && initializing)}> <InitializingContext.Provider value={!isMounted || (isMounted && initializing)}>
<ProcessingContext.Provider value={processing}> <ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}> <BackgroundProcessingContext.Provider value={backgroundProcessing}>
<FormFieldsContext.Provider value={fieldsReducer}> <ModifiedContext.Provider value={modified}>
{children} <FormFieldsContext.Provider value={fieldsReducer}>
</FormFieldsContext.Provider> {children}
</ModifiedContext.Provider> </FormFieldsContext.Provider>
</ModifiedContext.Provider>
</BackgroundProcessingContext.Provider>
</ProcessingContext.Provider> </ProcessingContext.Provider>
</InitializingContext.Provider> </InitializingContext.Provider>
</SubmittedContext.Provider> </SubmittedContext.Provider>

View File

@@ -22,6 +22,7 @@ const createFormData: CreateFormData = () => undefined
const setModified: SetModified = () => undefined const setModified: SetModified = () => undefined
const setProcessing: SetProcessing = () => undefined const setProcessing: SetProcessing = () => undefined
const setBackgroundProcessing: SetProcessing = () => undefined
const setSubmitted: SetSubmitted = () => undefined const setSubmitted: SetSubmitted = () => undefined
const reset: Reset = () => undefined const reset: Reset = () => undefined
@@ -44,6 +45,7 @@ export const initContextState: Context = {
replaceFieldRow: () => undefined, replaceFieldRow: () => undefined,
replaceState: () => undefined, replaceState: () => undefined,
reset, reset,
setBackgroundProcessing,
setDisabled: () => undefined, setDisabled: () => undefined,
setIsValid: () => undefined, setIsValid: () => undefined,
setModified, setModified,

View File

@@ -248,6 +248,11 @@ export type Context = {
}) => void }) => void
replaceState: (state: FormState) => void replaceState: (state: FormState) => void
reset: Reset reset: Reset
/**
* If the form has started processing in the background (e.g.
* if autosave is running), this will be true.
*/
setBackgroundProcessing: SetProcessing
setDisabled: (disabled: boolean) => void setDisabled: (disabled: boolean) => void
setIsValid: (processing: boolean) => void setIsValid: (processing: boolean) => void
setModified: SetModified setModified: SetModified

View File

@@ -4,7 +4,12 @@ import React from 'react'
import type { Props } from '../../elements/Button/types.js' import type { Props } from '../../elements/Button/types.js'
import { Button } from '../../elements/Button/index.js' import { Button } from '../../elements/Button/index.js'
import { useForm, useFormInitializing, useFormProcessing } from '../Form/context.js' import {
useForm,
useFormBackgroundProcessing,
useFormInitializing,
useFormProcessing,
} from '../Form/context.js'
import './index.scss' import './index.scss'
const baseClass = 'form-submit' const baseClass = 'form-submit'
@@ -21,10 +26,17 @@ export const FormSubmit: React.FC<Props> = (props) => {
} = props } = props
const processing = useFormProcessing() const processing = useFormProcessing()
const backgroundProcessing = useFormBackgroundProcessing()
const initializing = useFormInitializing() const initializing = useFormInitializing()
const { disabled, submit } = useForm() const { disabled, submit } = useForm()
const canSave = !(disabledFromProps || initializing || processing || disabled) const canSave = !(
disabledFromProps ||
initializing ||
processing ||
backgroundProcessing ||
disabled
)
const handleClick = const handleClick =
onClick ?? onClick ??