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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user