diff --git a/ui/app/src/components/FormsEngine/FormsEngine.tsx b/ui/app/src/components/FormsEngine/FormsEngine.tsx index 3c9f5317ea..a7cc72f90a 100644 --- a/ui/app/src/components/FormsEngine/FormsEngine.tsx +++ b/ui/app/src/components/FormsEngine/FormsEngine.tsx @@ -638,6 +638,9 @@ function FormOrchestrator(props: FormsEngineProps) { lockStatus }); const [collapseHeader, setCollapseHeader] = useState(false); + const [saveAsDraft, setSaveAsDraft] = useState(false); + const [invalidForm, setInvalidForm] = useState(false); + const jotai = useJotaiStore(); useMount(() => { // If 'update.changeTypeId' has content, it means the content type has changed, so we set pending changes to true @@ -645,6 +648,19 @@ function FormOrchestrator(props: FormsEngineProps) { if (update?.changeTypeId) { setHasPendingChanges(true); } + const checkValidationState = async () => { + const validityStates = await Promise.all( + Object.values(stableFormContext.atoms.validationByFieldId).map((validityDataAtom) => + jotai.get(validityDataAtom) + ) + ); + setInvalidForm(validityStates.some((state) => !state.isValid)); + }; + void checkValidationState(); + const subscription = stableFormContext.fieldUpdates$ + .pipe(debounceTime(300)) + .subscribe(() => void checkValidationState()); + return () => subscription.unsubscribe(); }); // Changes comment generation & change detection/tracking @@ -679,7 +695,7 @@ function FormOrchestrator(props: FormsEngineProps) { }, [isSubmitting, hasPendingChanges, isStackedForm, updateSubmittingOrHasPendingChanges]); // Unlock content when the form is closed. - useUnlockOnClose(props); + useUnlockOnClose({ ...props, saveAsDraft }); // region Workflow item updates useEffect(() => { @@ -978,7 +994,10 @@ function FormOrchestrator(props: FormsEngineProps) { isEmbedded={isEmbedded} isStackedForm={isStackedForm} isRepeatMode={isRepeatMode} - onSave={() => saveFn()} + saveAsDraft={saveAsDraft} + setSaveAsDraft={setSaveAsDraft} + invalidForm={invalidForm} + onSave={(e, draft) => saveFn(draft)} /> {!isCreateMode && // There's no locking on create mode (!isRepeatMode || (isRepeatMode && repeat.values)) && // No point in the "unlock" button for new repeat items diff --git a/ui/app/src/components/FormsEngine/components/SaveCard.tsx b/ui/app/src/components/FormsEngine/components/SaveCard.tsx index 9fbdacd3cd..2fe0940704 100644 --- a/ui/app/src/components/FormsEngine/components/SaveCard.tsx +++ b/ui/app/src/components/FormsEngine/components/SaveCard.tsx @@ -14,55 +14,38 @@ * along with this program. If not, see . */ -import { useAtom, useAtomValue, useStore as useJotaiStore } from 'jotai'; -import { FormattedMessage } from 'react-intl'; -import React, { useContext, useState } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { FormattedMessage, useIntl } from 'react-intl'; +import React, { MouseEvent, useContext } from 'react'; import { StableFormContext } from '../lib/formsEngineContext'; -import { ButtonProps } from '@mui/material/Button'; import Paper from '@mui/material/Paper'; import TextField from '@mui/material/TextField'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; -import PrimaryButton from '../../PrimaryButton'; import FormHelperText from '@mui/material/FormHelperText'; import Grow from '@mui/material/Grow'; import Alert from '@mui/material/Alert'; -import useMount from '../../../hooks/useMount'; -import { debounceTime } from 'rxjs/operators'; +import { SplitButton } from '../../SplitButton'; export interface SaveCardProps { isRepeatMode: boolean; isStackedForm: boolean; isEmbedded: boolean; - onSave: ButtonProps['onClick']; + saveAsDraft: boolean; + setSaveAsDraft: (value: boolean) => void; + invalidForm: boolean; + onSave: (e: MouseEvent, draft?: boolean) => void; } export function SaveCard(props: SaveCardProps) { - const { isEmbedded, isStackedForm, isRepeatMode, onSave } = props; + const { isEmbedded, isStackedForm, isRepeatMode, setSaveAsDraft, invalidForm, onSave } = props; const stableFormContext = useContext(StableFormContext); const isSubmitting = useAtomValue(stableFormContext.atoms.isSubmitting); const [versionComment, setVersionComment] = useAtom(stableFormContext.atoms.versionComment); const hasPendingChanges = useAtomValue(stableFormContext.atoms.hasPendingChanges); const [closeAfterSave, setCloseAfterSave] = useAtom(stableFormContext.atoms.closeAfterSave); - const jotai = useJotaiStore(); - const [saveAsDraft, setSaveAsDraft] = useState(null); - - useMount(() => { - const checkValidationState = async () => { - const validityStates = await Promise.all( - Object.values(stableFormContext.atoms.validationByFieldId).map((validityDataAtom) => - jotai.get(validityDataAtom) - ) - ); - setSaveAsDraft(validityStates.some((state) => !state.isValid)); - }; - void checkValidationState(); - const subscription = stableFormContext.fieldUpdates$ - .pipe(debounceTime(300)) - .subscribe(() => void checkValidationState()); - return () => subscription.unsubscribe(); - }); const disableSave = isSubmitting || !hasPendingChanges; + const { formatMessage } = useIntl(); return ( {(!isEmbedded || !isStackedForm) && !isRepeatMode && ( @@ -88,19 +71,37 @@ export function SaveCard(props: SaveCardProps) { - If validations aren't all passed, should read "Save Draft" and a different colour. - What about embedded drafts? Should they be allowed? */} - - {isRepeatMode || (isEmbedded && isStackedForm) ? ( - saveAsDraft ? ( - - ) : ( - - ) - ) : saveAsDraft ? ( - - ) : ( - - )} - + { + setSaveAsDraft(false); + onSave(e); + } + }, + { + id: 'saveDraft', + label: + isRepeatMode || (isEmbedded && isStackedForm) + ? formatMessage({ defaultMessage: 'Done (Draft)' }) + : formatMessage({ defaultMessage: 'Save Draft' }), + callback: (e) => { + setSaveAsDraft(true); + onSave(e, true); + } + } + ]} + disabledOptions={invalidForm ? ['save'] : []} + /> {isStackedForm && isEmbedded && ( diff --git a/ui/app/src/components/FormsEngine/lib/formUtils.tsx b/ui/app/src/components/FormsEngine/lib/formUtils.tsx index 5fc1363e76..ba91853827 100644 --- a/ui/app/src/components/FormsEngine/lib/formUtils.tsx +++ b/ui/app/src/components/FormsEngine/lib/formUtils.tsx @@ -635,14 +635,28 @@ export interface ShouldUnlockArguments { isParentReadonly: boolean; siteId: string; isRenamed: boolean; + saveAsDraft: boolean; + invalidForm: boolean; } /** * Determines if an item should be unlocked when its form is being unmounted. **/ export function shouldUnlockItem(props: ShouldUnlockArguments): boolean { - const { isRepeatMode, isCreateMode, readonly, isEmbedded, isStackedForm, isParentReadonly, isRenamed } = props; + const { + isRepeatMode, + isCreateMode, + readonly, + isEmbedded, + isStackedForm, + isParentReadonly, + isRenamed, + saveAsDraft, + invalidForm + } = props; return ( + !invalidForm && + !saveAsDraft && !isRenamed && !isRepeatMode && !isCreateMode && @@ -661,8 +675,8 @@ export function shouldUnlockItem(props: ShouldUnlockArguments): boolean { * When the consumer component is being unmounted, checks if it should be unlocked and unlocks if so. * @param props {FormsEngineProps} **/ -export function useUnlockOnClose(props: FormsEngineProps) { - const { create, update, repeat, stackIndex = 0 } = props; +export function useUnlockOnClose(props: FormsEngineProps & { saveAsDraft?: boolean; invalidForm?: boolean }) { + const { create, update, repeat, stackIndex = 0, saveAsDraft = false, invalidForm } = props; const itemPath = useContext(ItemContext)?.path; const { atoms } = useContext(StableFormContext); const { formsStackData } = useContext(StableGlobalContext); @@ -688,7 +702,9 @@ export function useUnlockOnClose(props: FormsEngineProps) { isStackedForm, isParentReadonly: formsStackData[stackIndex - 1] ? store.get(formsStackData[stackIndex - 1].atoms.readonly) : false, siteId, - isRenamed + isRenamed, + saveAsDraft, + invalidForm }); useEffect( () => () => { @@ -706,7 +722,7 @@ export function useUnlockOnClose(props: FormsEngineProps) { }); } }, - [itemPath, unlockEffectRefs] + [itemPath, unlockEffectRefs, saveAsDraft] ); } diff --git a/ui/app/src/components/FormsEngine/lib/useSaveForm.tsx b/ui/app/src/components/FormsEngine/lib/useSaveForm.tsx index db2b92dd98..351c4c2957 100644 --- a/ui/app/src/components/FormsEngine/lib/useSaveForm.tsx +++ b/ui/app/src/components/FormsEngine/lib/useSaveForm.tsx @@ -90,13 +90,14 @@ export function useSaveForm(props: UseSaveFormProps) { const { setRenamedPath } = useContext(RenamedPathContext); const initialFileName = itemPath ? getFileNameValueFromPath(itemPath, isPage) : ''; const item = useContext(ItemContext); - return async () => { + return async (draft?: boolean) => { const values = extractAtomValues(jotai, stableFormContext.atoms.valueByFieldId); const validityStates = await Promise.all( Object.values(stableFormContext.atoms.validationByFieldId).map((validityDataAtom) => jotai.get(validityDataAtom)) ); // Put system properties in before creating the XML - const saveAsDraft = validityStates.some((state) => !state.isValid); + const isFormInvalid = validityStates.some((state) => !state.isValid); + const saveAsDraft = draft || isFormInvalid; const onSavePromiseHandler = ({ close }: FormSavePromiseResult) => { if (saveAsDraft) { diff --git a/ui/app/src/components/SplitButton/SplitButton.tsx b/ui/app/src/components/SplitButton/SplitButton.tsx index ed505a9db2..53fcb8d719 100644 --- a/ui/app/src/components/SplitButton/SplitButton.tsx +++ b/ui/app/src/components/SplitButton/SplitButton.tsx @@ -25,36 +25,62 @@ import { import useActiveUser from '../../hooks/useActiveUser'; export function SplitButton(props: SplitButtonProps) { - const { options, defaultSelected = options[0].id, disablePortal = true, disabled, loading, storageKey } = props; + const { + options, + disabledOptions = [], + defaultSelected = options[0].id, + disablePortal = true, + disabled, + loading, + storageKey, + fullWidth, + selectedIndex: controlledSelectedIndex, + onSelectedIndexChange + } = props; const [open, setOpen] = React.useState(false); const user = useActiveUser(); const anchorRef = React.useRef(null); const indexFromDefaultSelected = options.findIndex((option) => option.id === defaultSelected); - const [selectedIndex, setSelectedIndex] = React.useState( + const [uncontrolledSelectedIndex, setUncontrolledSelectedIndex] = React.useState( indexFromDefaultSelected !== -1 ? indexFromDefaultSelected : 0 ); + // Use controlled selectedIndex if provided, otherwise use internal state + const selectedIndex = controlledSelectedIndex !== undefined ? controlledSelectedIndex : uncontrolledSelectedIndex; + useEffect(() => { if (storageKey) { const storedValue = getStoredSaveButtonSubAction(user.username, storageKey); if (storedValue) { const index = options.findIndex((option) => option.id === storedValue); if (index !== -1) { - setSelectedIndex(index); + if (controlledSelectedIndex === undefined) { + setUncontrolledSelectedIndex(index); + } } else { removeStoredSaveButtonSubAction(user.username, storageKey); } } } + // Only update internal state if uncontrolled + // eslint-disable-next-line react-hooks/exhaustive-deps }, [storageKey, options, user.username]); const handleClick = (e) => { options[selectedIndex]?.callback(e); + if (onSelectedIndexChange) { + onSelectedIndexChange(selectedIndex); + } }; const handleMenuItemClick = (event: React.MouseEvent, index: number) => { - setSelectedIndex(index); + if (controlledSelectedIndex === undefined) { + setUncontrolledSelectedIndex(index); + } + if (onSelectedIndexChange) { + onSelectedIndexChange(index); + } if (storageKey) { const storageValue = options[index].id; @@ -80,6 +106,7 @@ export function SplitButton(props: SplitButtonProps) { return ( ); } diff --git a/ui/app/src/components/SplitButton/SplitButtonUI.tsx b/ui/app/src/components/SplitButton/SplitButtonUI.tsx index be5b46655a..c9a9ad1bdb 100644 --- a/ui/app/src/components/SplitButton/SplitButtonUI.tsx +++ b/ui/app/src/components/SplitButton/SplitButtonUI.tsx @@ -14,7 +14,7 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useState, useLayoutEffect } from 'react'; import ButtonGroup from '@mui/material/ButtonGroup'; import Button from '@mui/material/Button'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; @@ -29,6 +29,7 @@ import { SplitButtonUIProps } from './utils'; export function SplitButtonUI(props: SplitButtonUIProps) { const { options, + disabledOptions, disablePortal, disabled, anchorRef, @@ -38,13 +39,38 @@ export function SplitButtonUI(props: SplitButtonUIProps) { handleToggle, handleClose, handleMenuItemClick, - loading + loading, + fullWidth } = props; + // Store the width of the anchor element (ButtonGroup) if fullWidth is true + const [popperWidth, setPopperWidth] = useState(undefined); + + useLayoutEffect(() => { + if (fullWidth && anchorRef?.current) { + setPopperWidth(anchorRef.current.offsetWidth); + } else { + setPopperWidth(undefined); + } + }, [fullWidth, anchorRef, open]); + return ( <> - - {options.length > 1 && ( @@ -57,12 +83,20 @@ export function SplitButtonUI(props: SplitButtonUIProps) { aria-label="select option" aria-haspopup="menu" onClick={handleToggle} + sx={{ flex: fullWidth ? 1 : 'unset' }} > )} - + anchorRef.current} + role={undefined} + transition + disablePortal={disablePortal} + sx={{ zIndex: 2 }} + > {({ TransitionProps, placement }) => ( - + {options.map((option, index) => ( handleMenuItemClick(event, index)} > {option.label} diff --git a/ui/app/src/components/SplitButton/utils.ts b/ui/app/src/components/SplitButton/utils.ts index e2e994add0..b19833c1e5 100644 --- a/ui/app/src/components/SplitButton/utils.ts +++ b/ui/app/src/components/SplitButton/utils.ts @@ -24,20 +24,32 @@ export interface SplitButtonOption { export interface SplitButtonProps { options: SplitButtonOption[]; + disabledOptions?: string[]; defaultSelected?: string; disablePortal?: boolean; disabled?: boolean; loading?: boolean; storageKey?: string; + fullWidth?: boolean; + /** + * If provided, SplitButton will be controlled and use this value as the selected index. + */ + selectedIndex?: number; + /** + * Callback fired when the selected index changes (from menu or click). + */ + onSelectedIndexChange?: (index: number) => void; } export interface SplitButtonUIProps { options: SplitButtonOption[]; + disabledOptions?: SplitButtonProps['disabledOptions']; disablePortal?: boolean; loading?: boolean; disabled?: boolean; anchorRef: MutableRefObject; selectedIndex: number; + fullWidth?: SplitButtonProps['fullWidth']; open: boolean; handleClick(e): void; handleToggle(e): void;