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 (
<>
-
-
)}
-
+ anchorRef.current}
+ role={undefined}
+ transition
+ disablePortal={disablePortal}
+ sx={{ zIndex: 2 }}
+ >
{({ TransitionProps, placement }) => (
-
+