fix: prevent multiple submits while creating units [FC-0083] (#1776)

Fixes a bug where the form is submitted multiple times when the user presses Enter on the Unit create form.

The issue was that when the user hit Enter, the form was submitted without calling the button's onClick, allowing multiple calls. Also, because the onClick was not called, we had to add the isLoading property to the LoadingButton to display the status correctly.
This commit is contained in:
Rômulo Penido
2025-04-07 11:24:51 -03:00
committed by GitHub
parent fdd8928f36
commit 2a31434a55
4 changed files with 96 additions and 62 deletions

View File

@@ -72,4 +72,24 @@ describe('<LoadingButton />', () => {
expect(buttonElement).not.toHaveAttribute('aria-disabled', 'true');
expect(container.getElementsByClassName('icon-spin').length).toBe(0);
});
it('doesnt run the onClick function if the button is loading (disabled)', async () => {
let resolver: () => void;
const promise = new Promise<void>((resolve) => {
resolver = resolve;
});
const longFunction = jest.fn().mockReturnValue(promise);
const { getByRole } = render(RootWrapper(longFunction));
const buttonElement = getByRole('button');
fireEvent.click(buttonElement);
// First click should call the function
expect(longFunction).toHaveBeenCalledTimes(1);
fireEvent.click(buttonElement);
// Second click should not call the function, because the button is disabled
expect(longFunction).toHaveBeenCalledTimes(1);
await act(async () => { resolver(); });
// After the promise is resolved, the button should be enabled
fireEvent.click(buttonElement);
expect(longFunction).toHaveBeenCalledTimes(2);
});
});

View File

@@ -8,13 +8,12 @@ import {
StatefulButton,
} from '@openedx/paragon';
interface LoadingButtonProps {
interface LoadingButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
onClick?: (e: any) => (Promise<void> | void);
disabled?: boolean;
size?: string;
variant?: string;
className?: string;
isLoading?: boolean;
}
/**
@@ -26,17 +25,33 @@ const LoadingButton: React.FC<LoadingButtonProps> = ({
disabled,
size,
variant,
className,
isLoading,
...props
}) => {
const [state, setState] = useState('');
// This is used to prevent setting the isLoading state after the component has been unmounted.
// Button state depends on isLoading and disabled flags
const getState = () => {
if (isLoading) { return 'pending'; }
if (disabled) { return 'disabled'; }
return '';
};
const [state, setState] = useState(getState);
useEffect(() => {
setState(getState);
}, [isLoading, disabled]);
const componentMounted = useRef(true);
// This is used to prevent setting the isLoading state after the component has been unmounted.
useEffect(() => () => {
componentMounted.current = false;
}, []);
const loadingOnClick = useCallback(async (e: any) => {
if (disabled) {
return;
}
if (!onClick) {
return;
}
@@ -55,13 +70,13 @@ const LoadingButton: React.FC<LoadingButtonProps> = ({
return (
<StatefulButton
disabledStates={disabled ? [state] : ['pending'] /* StatefulButton doesn't support disabled prop */}
disabledStates={['disabled', 'pending'] /* StatefulButton doesn't support disabled prop */}
onClick={loadingOnClick}
labels={{ default: label }}
state={state}
size={size}
variant={variant}
className={className}
{...props}
/>
);
};

View File

@@ -1,7 +1,6 @@
import React from 'react';
import {
ActionRow,
Button,
Form,
ModalDialog,
} from '@openedx/paragon';
@@ -10,6 +9,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik } from 'formik';
import * as Yup from 'yup';
import FormikControl from '../../generic/FormikControl';
import LoadingButton from '../../generic/loading-button';
import { useLibraryContext } from '../common/context/LibraryContext';
import messages from './messages';
import { useCreateLibraryCollection } from '../data/apiHooks';
@@ -67,55 +67,54 @@ const CreateCollectionModal = () => {
onSubmit={handleCreate}
>
{(formikProps) => (
<>
<Form onSubmit={formikProps.handleSubmit}>
<ModalDialog.Body className="mw-sm">
<Form onSubmit={formikProps.handleSubmit}>
<FormikControl
name="title"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createCollectionModalNameLabel)}
</Form.Label>
)}
value={formikProps.values.title}
placeholder={intl.formatMessage(messages.createCollectionModalNamePlaceholder)}
controlClasses="pb-2"
/>
<FormikControl
name="description"
as="textarea"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createCollectionModalDescriptionLabel)}
</Form.Label>
)}
value={formikProps.values.description}
placeholder={intl.formatMessage(messages.createCollectionModalDescriptionPlaceholder)}
help={(
<Form.Text>
{intl.formatMessage(messages.createCollectionModalDescriptionDetails)}
</Form.Text>
)}
controlClasses="pb-2"
rows="5"
/>
</Form>
<FormikControl
name="title"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createCollectionModalNameLabel)}
</Form.Label>
)}
value={formikProps.values.title}
placeholder={intl.formatMessage(messages.createCollectionModalNamePlaceholder)}
controlClasses="pb-2"
/>
<FormikControl
name="description"
as="textarea"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createCollectionModalDescriptionLabel)}
</Form.Label>
)}
value={formikProps.values.description}
placeholder={intl.formatMessage(messages.createCollectionModalDescriptionPlaceholder)}
help={(
<Form.Text>
{intl.formatMessage(messages.createCollectionModalDescriptionDetails)}
</Form.Text>
)}
controlClasses="pb-2"
rows="5"
/>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.createCollectionModalCancel)}
</ModalDialog.CloseButton>
<Button
<LoadingButton
variant="primary"
onClick={formikProps.submitForm}
disabled={formikProps.isSubmitting || !formikProps.isValid || !formikProps.dirty}
>
{intl.formatMessage(messages.createCollectionModalCreate)}
</Button>
disabled={!formikProps.isValid || !formikProps.dirty}
isLoading={formikProps.isSubmitting}
label={intl.formatMessage(messages.createCollectionModalCreate)}
type="submit"
/>
</ActionRow>
</ModalDialog.Footer>
</>
</Form>
)}
</Formik>
</ModalDialog>

View File

@@ -68,21 +68,19 @@ const CreateUnitModal = () => {
onSubmit={handleCreate}
>
{(formikProps) => (
<>
<Form onSubmit={formikProps.handleSubmit}>
<ModalDialog.Body className="mw-sm">
<Form onSubmit={formikProps.handleSubmit}>
<FormikControl
name="displayName"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createUnitModalNameLabel)}
</Form.Label>
)}
value={formikProps.values.displayName}
placeholder={intl.formatMessage(messages.createUnitModalNamePlaceholder)}
controlClasses="pb-2"
/>
</Form>
<FormikControl
name="displayName"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createUnitModalNameLabel)}
</Form.Label>
)}
value={formikProps.values.displayName}
placeholder={intl.formatMessage(messages.createUnitModalNamePlaceholder)}
controlClasses="pb-2"
/>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
@@ -93,11 +91,13 @@ const CreateUnitModal = () => {
variant="primary"
onClick={formikProps.submitForm}
disabled={!formikProps.isValid || !formikProps.dirty}
isLoading={formikProps.isSubmitting}
label={intl.formatMessage(messages.createUnitModalCreate)}
type="submit"
/>
</ActionRow>
</ModalDialog.Footer>
</>
</Form>
)}
</Formik>
</ModalDialog>