Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions ui/app/src/components/FormsEngine/FormsEngine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -638,13 +638,29 @@ 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
// to be able to enable the save button and allow users to save immediately if that's all they want to do.
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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
Expand Down
81 changes: 41 additions & 40 deletions ui/app/src/components/FormsEngine/components/SaveCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,55 +14,38 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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<boolean | null>(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 (
<Paper sx={{ p: 1 }}>
{(!isEmbedded || !isStackedForm) && !isRepeatMode && (
Expand All @@ -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?
*/}
<PrimaryButton fullWidth variant="contained" onClick={onSave} disabled={disableSave} loading={isSubmitting}>
{isRepeatMode || (isEmbedded && isStackedForm) ? (
saveAsDraft ? (
<FormattedMessage defaultMessage="Done (Draft)" />
) : (
<FormattedMessage defaultMessage="Done" />
)
) : saveAsDraft ? (
<FormattedMessage defaultMessage="Save Draft" />
) : (
<FormattedMessage defaultMessage="Save" />
)}
</PrimaryButton>
<SplitButton
fullWidth
loading={isSubmitting}
disabled={disableSave}
selectedIndex={invalidForm ? 1 : undefined}
options={[
{
id: 'save',
label:
isRepeatMode || (isEmbedded && isStackedForm)
? formatMessage({ defaultMessage: 'Done' })
: formatMessage({ defaultMessage: 'Save' }),
callback: (e) => {
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 && (
<FormHelperText sx={{ textAlign: 'center' }}>
<FormattedMessage defaultMessage="Changes are saved with the main item." />
Expand Down
26 changes: 21 additions & 5 deletions ui/app/src/components/FormsEngine/lib/formUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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);
Expand All @@ -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(
() => () => {
Expand All @@ -706,7 +722,7 @@ export function useUnlockOnClose(props: FormsEngineProps) {
});
}
},
[itemPath, unlockEffectRefs]
[itemPath, unlockEffectRefs, saveAsDraft]
);
}

Expand Down
5 changes: 3 additions & 2 deletions ui/app/src/components/FormsEngine/lib/useSaveForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 32 additions & 4 deletions ui/app/src/components/SplitButton/SplitButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(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<Element, MouseEvent>, index: number) => {
setSelectedIndex(index);
if (controlledSelectedIndex === undefined) {
setUncontrolledSelectedIndex(index);
}
if (onSelectedIndexChange) {
onSelectedIndexChange(index);
}

if (storageKey) {
const storageValue = options[index].id;
Expand All @@ -80,6 +106,7 @@ export function SplitButton(props: SplitButtonProps) {
return (
<SplitButtonUI
options={options}
disabledOptions={disabledOptions}
loading={loading}
disablePortal={disablePortal}
disabled={disabled}
Expand All @@ -90,6 +117,7 @@ export function SplitButton(props: SplitButtonProps) {
handleToggle={handleToggle}
handleClose={handleClose}
handleMenuItemClick={handleMenuItemClick}
fullWidth={fullWidth}
/>
);
}
Expand Down
Loading