feat: [MICROBA-1620] Wire BulkEmailForm to API (#11)

- Wires up the BulkEmailForm to be able to actually send email tasks to
  the instructor api
- Adds testing for the BulkEmailForm, as well as BulkEmailTool itself
- Does some cleanup, and adds some additional testing tools as needed
This commit is contained in:
Thomas Tracy
2022-01-27 14:51:17 -05:00
committed by GitHub
parent 95b960964c
commit ce9cdf642b
21 changed files with 900 additions and 108 deletions

View File

@@ -1,44 +0,0 @@
import React, { useRef } from 'react';
import { Button, Form } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import TextEditor from './TextEditor';
export default function BulkEmailBody() {
const editorRef = useRef(null);
const onInit = (event, editor) => { editorRef.current = editor; };
return (
<div className="w-100 m-auto p-lg-4 p-2.5">
<Form>
<Form.Group controlId="emailSubject">
<Form.Label>
<FormattedMessage
id="bulk.email.subject.label"
defaultMessage="Subject:"
description="Email subject line input label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<Form.Control className="w-lg-50" />
</Form.Group>
<Form.Group controlId="emailBody">
<Form.Label>
<FormattedMessage
id="bulk.email.body.label"
defaultMessage="Body:"
description="Email Body label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<TextEditor onInit={onInit} />
</Form.Group>
<Button variant="primary" type="submit">
<FormattedMessage
id="bulk.email.submit.button"
defaultMessage="Submit"
description="Submit/Send email button"
/>
</Button>
</Form>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function BulkEmailRecepient() {
return <div />;
}

View File

@@ -4,12 +4,11 @@ import classnames from 'classnames';
import { useParams } from 'react-router-dom';
import { Spinner } from '@edx/paragon';
import { ErrorPage } from '@edx/frontend-platform/react';
import BulkEmailRecepient from './BulkEmailRecepient';
import BulkEmailBody from './BulkEmailBody';
import BulkEmailTaskManager from './bulk-email-task-manager/BulkEmailTaskManager';
import Navigationtabs from '../navigation-tabs/NavigationTabs';
import { getCourseHomeCourseMetadata } from './api';
import { getCourseHomeCourseMetadata } from './data/api';
import useMobileResponsive from '../../utils/useMobileResponsive';
import BulkEmailForm from './bulk-email-form';
export default function BulkEmailTool() {
const { courseId } = useParams();
@@ -39,23 +38,20 @@ export default function BulkEmailTool() {
}, []);
if (courseMetadata) {
return (
courseMetadata.isStaff ? (
<div>
<Navigationtabs courseId={courseId} tabData={courseMetadata.tabs} />
<div className={classnames({ 'border border-primary-200': !isMobile })}>
<div className="row">
<BulkEmailRecepient courseId={courseId} />
</div>
<div className="row">
<BulkEmailBody courseId={courseId} />
</div>
<div className="row">
<BulkEmailTaskManager courseId={courseId} />
</div>
return courseMetadata.isStaff ? (
<div>
<Navigationtabs courseId={courseId} tabData={courseMetadata.tabs} />
<div className={classnames({ 'border border-primary-200': !isMobile })}>
<div className="row">
<BulkEmailForm courseId={courseId} />
</div>
<div className="row">
<BulkEmailTaskManager courseId={courseId} />
</div>
</div>
) : <ErrorPage />
</div>
) : (
<ErrorPage />
);
}
return (
@@ -64,7 +60,7 @@ export default function BulkEmailTool() {
animation="border"
variant="primary"
role="status"
screenReaderText="loading"
screenreadertext="loading"
className="spinner-border spinner-border-lg text-primary p-5 m-5"
/>
</div>

View File

@@ -0,0 +1,225 @@
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import {
Form, Icon, StatefulButton, useCheckboxSetValues, useToggle,
} from '@edx/paragon';
import { SpinnerSimple, CheckCircle, Cancel } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import TextEditor from '../text-editor/TextEditor';
import { postBulkEmail } from './api';
import BulkEmailRecipient from './BulkEmailRecipient';
import TaskAlertModal from './TaskAlertModal';
import useTimeout from '../../../utils/useTimeout';
export const FORM_SUBMIT_STATES = {
DEFAULT: 'default',
PENDING: 'pending',
COMPLETE: 'complete',
COMPLETED_DEFAULT: 'completed_default',
ERROR: 'error',
};
export default function BulkEmailForm(props) {
const { courseId } = props;
const [subject, setSubject] = useState('');
const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT);
const [emailFormValidation, setEmailFormValidation] = useState({
// set these as true on initialization, to prevent invalid messages from prematurely showing
subject: true,
body: true,
recipients: true,
});
const [selectedRecipients, { add, remove }] = useCheckboxSetValues([]);
const [isTaskAlertOpen, openTaskAlert, closeTaskAlert] = useToggle(false);
const editorRef = useRef(null);
const resetEmailForm = useTimeout(() => {
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETED_DEFAULT);
}, 3000);
const onRecipientChange = (event) => {
if (event.target.checked) {
add(event.target.value);
} else {
remove(event.target.value);
}
};
const onInit = (event, editor) => {
editorRef.current = editor;
};
const onSubjectChange = (event) => setSubject(event.target.value);
const validateEmailForm = () => {
const subjectValid = subject.length !== 0;
const bodyValid = editorRef.current.getContent().length !== 0;
const recipientsValid = selectedRecipients.length !== 0;
setEmailFormValidation({
subject: subjectValid,
recipients: recipientsValid,
body: bodyValid,
});
return subjectValid && bodyValid && recipientsValid;
};
const createEmailTask = async () => {
const emailData = new FormData();
if (validateEmailForm()) {
setEmailFormStatus(() => FORM_SUBMIT_STATES.PENDING);
emailData.append('action', 'send');
emailData.append('send_to', JSON.stringify(selectedRecipients));
emailData.append('subject', subject);
emailData.append('message', editorRef.current.getContent());
let data;
try {
data = await postBulkEmail(emailData, courseId);
} catch (e) {
setEmailFormStatus(FORM_SUBMIT_STATES.ERROR);
return;
}
if (data.status === 200) {
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE);
resetEmailForm();
}
}
};
return (
<div className="w-100 m-auto p-lg-4 py-2.5 px-5">
<TaskAlertModal
isOpen={isTaskAlertOpen}
alertMessage={(
<>
<p>
<FormattedMessage
id="bulk.email.task.alert.recipients"
defaultMessage="You are sending an email message with the subject {subject} to the following recipients:"
description="A warning shown to the user after submitting the email, to confirm the email recipients."
values={{
subject,
}}
/>
</p>
<ul>
{selectedRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
<p>
<FormattedMessage
id="bulk.email.task.alert.warning"
defaultMessage="CAUTION! When you select Send Email, your email message is added to the queue for sending, and cannot be cancelled."
description="Warns the user in an alert that the email may not be immediately sent out to users."
/>
</p>
</>
)}
close={(event) => {
closeTaskAlert();
if (event.target.name === 'continue') {
createEmailTask();
}
}}
/>
<Form>
<BulkEmailRecipient
selectedGroups={selectedRecipients}
handleCheckboxes={onRecipientChange}
isValid={emailFormValidation.recipients}
/>
<Form.Group controlId="emailSubject">
<Form.Label>
<FormattedMessage
id="bulk.email.subject.label"
defaultMessage="Subject:"
description="Email subject line input label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<Form.Control name="subject" className="w-lg-50" onChange={onSubjectChange} />
{!emailFormValidation.subject && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.subject.error"
defaultMessage="A subject is required"
description="An Error message located under the subject line. Visible only on failure."
/>
</Form.Control.Feedback>
)}
</Form.Group>
<Form.Group controlId="emailBody">
<Form.Label>
<FormattedMessage
id="bulk.email.body.label"
defaultMessage="Body:"
description="Email Body label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<TextEditor onInit={onInit} />
{!emailFormValidation.body && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.body.error"
defaultMessage="The message cannot be blank"
description="An error message located under the body editor. Visible only on failure."
/>
</Form.Control.Feedback>
)}
</Form.Group>
<Form.Group className="d-flex flex-row">
<StatefulButton
variant="primary"
type="submit"
onClick={(event) => {
event.preventDefault();
openTaskAlert();
}}
state={emailFormStatus}
icons={{
default: <Icon className="icon-download" />,
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
complete: <Icon src={CheckCircle} />,
error: <Icon src={Cancel} />,
}}
labels={{
default: 'Submit',
pending: 'Submitting',
complete: 'Task Created',
error: 'Error',
}}
disabledStates={['pending', 'complete']}
>
<FormattedMessage
id="bulk.email.submit.button"
defaultMessage="Submit"
description="Submit/Send email button"
/>
</StatefulButton>
{emailFormStatus === FORM_SUBMIT_STATES.ERROR && (
<Form.Control.Feedback className="px-3" hasIcon={false} type="invalid">
<FormattedMessage
id="bulk.email.form.error"
defaultMessage="An error occured while attempting to send the email."
description="An Error message located under the submit button for the email form. Visible only on a failure."
/>
</Form.Control.Feedback>
)}
{(emailFormStatus === FORM_SUBMIT_STATES.COMPLETED_DEFAULT
|| emailFormStatus === FORM_SUBMIT_STATES.COMPLETE) && (
<Form.Control.Feedback className="px-3" hasIcon={false} type="valid">
<FormattedMessage
id="bulk.email.form.complete"
defaultMessage="A task to send the emails has been successfully created!"
description="A success message displays under the submit button when successfully completing the form."
/>
</Form.Control.Feedback>
)}
</Form.Group>
</Form>
</div>
);
}
BulkEmailForm.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const DEFAULT_GROUPS = {
SELF: 'myself',
STAFF: 'staff',
ALL_LEARNERS: 'learners',
VERIFIED: 'track:verified',
AUDIT: 'track:audit',
};
export default function BulkEmailRecipient(props) {
const { handleCheckboxes, selectedGroups } = props;
return (
<Form.Group>
<Form.Label>
<FormattedMessage
id="bulk.email.form.recipients.sendLabel"
defaultMessage="Send To:"
description="A label before the list of potential recipients"
/>
</Form.Label>
<Form.CheckboxSet name="recipientGroups" onChange={handleCheckboxes} value={selectedGroups}>
<Form.Checkbox key="myself" value="myself">
<FormattedMessage
id="bulk.email.form.recipients.myself"
defaultMessage="Myself"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox key="staff" value="staff">
<FormattedMessage
id="bulk.email.form.recipients.staff"
defaultMessage="Staff/Administrators"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox
key="track:audit"
value="track:audit"
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
>
<FormattedMessage
id="bulk.email.form.recipients.audit"
defaultMessage="Learners in the audit track"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox
key="track:verified"
value="track:verified"
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
>
<FormattedMessage
id="bulk.email.form.recipients.verified"
defaultMessage="Learners in the verified certificate track"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox
key="learners"
value="learners"
disabled={selectedGroups.find((group) => group === (DEFAULT_GROUPS.AUDIT || DEFAULT_GROUPS.VERIFIED))}
>
<FormattedMessage
id="bulk.email.form.recipients.learners"
defaultMessage="All Learners"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
</Form.CheckboxSet>
{!props.isValid && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.recipients.error"
defaultMessage="At least one recipient is required"
description="An Error message located under the recipients list. Visible only on failure"
/>
</Form.Control.Feedback>
)}
</Form.Group>
);
}
BulkEmailRecipient.defaultProps = {
isValid: true,
};
BulkEmailRecipient.propTypes = {
selectedGroups: PropTypes.arrayOf(PropTypes.string).isRequired,
handleCheckboxes: PropTypes.func.isRequired,
isValid: PropTypes.bool,
};

View File

@@ -0,0 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
function TaskAlertModal(props) {
const {
isOpen, close, alertMessage, intl,
} = props;
const messages = {
taskAlertTitle: {
id: 'bulk.email.task.alert.title',
defaultMessage: 'Caution',
description: 'Title in the header of the alert',
},
};
return (
<AlertModal
title={intl.formatMessage(messages.taskAlertTitle)}
isBlocking
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={close} name="cancel">
<FormattedMessage
id="bulk.email.task.alert.cancel"
defaultMessage="Cancel"
description="Cancel button for the task alert"
/>
</Button>
<Button variant="primary" onClick={close} name="continue">
<FormattedMessage
id="bulk.email.form.recipients.Contine"
defaultMessage="Continue"
description="Continue button for the task alert"
/>
</Button>
</ActionRow>
)}
>
{alertMessage}
</AlertModal>
);
}
TaskAlertModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
alertMessage: PropTypes.node.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(TaskAlertModal);

View File

@@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export async function postBulkEmail(email, courseId) {
const url = `${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor/api/send_email`;
return getAuthenticatedHttpClient().post(url, email);
}

View File

@@ -0,0 +1 @@
export { default } from './BulkEmailForm';

View File

@@ -0,0 +1,65 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import {
render, screen, cleanup, act, fireEvent,
} from '../../../../setupTest';
import BulkEmailForm from '..';
import { postBulkEmail } from '../api';
jest.mock('../../text-editor/TextEditor');
jest.mock('../api', () => ({
__esModule: true,
postBulkEmail: jest.fn(() => ({ status: 200 })),
}));
describe('bulk-email-form', () => {
beforeEach(() => jest.resetModules());
afterEach(cleanup);
test('it renders', () => {
render(<BulkEmailForm courseId="test-course-id" />);
expect(screen.getByText('Submit')).toBeTruthy();
});
test('it shows a warning when clicking submit', async () => {
render(<BulkEmailForm courseId="test-course-id" />);
fireEvent.click(screen.getByText('Submit'));
const warning = await screen.findByText('CAUTION!', { exact: false });
expect(warning).toBeTruthy();
});
test('Prevent form POST if invalid', async () => {
render(<BulkEmailForm courseId="test-course-id" />);
fireEvent.click(screen.getByText('Submit'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('At least one recipient is required', { exact: false })).toBeInTheDocument();
expect(await screen.findByText('A subject is required')).toBeInTheDocument();
});
test('Shows complete message on completed POST', async () => {
render(<BulkEmailForm courseId="test-course-id" />);
fireEvent.click(screen.getByRole('checkbox', { name: 'Myself' }));
expect(screen.getByRole('checkbox', { name: 'Myself' })).toBeChecked();
fireEvent.change(screen.getByRole('textbox', { name: 'Subject:' }), { target: { value: 'test subject' } });
fireEvent.click(screen.getByText('Submit'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('Submitting')).toBeInTheDocument();
expect(await screen.findByText('A task to send the emails has been successfully created!')).toBeInTheDocument();
});
test('Shows Error on failed POST', async () => {
postBulkEmail.mockImplementation(() => {
throw Error('api-response-error');
});
await act(async () => {
render(<BulkEmailForm courseId="test-course-id" />);
const subjectLine = screen.getByRole('textbox', { name: 'Subject:' });
const recipient = screen.getByRole('checkbox', { name: 'Myself' });
fireEvent.click(recipient);
fireEvent.change(subjectLine, { target: { value: 'test subject' } });
fireEvent.click(screen.getByText('Submit'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('Error')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,77 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import './tab.factory';
export default Factory.define('courseMetadata')
.sequence('id', i => `course-v1:edX+DemoX+Demo_Course_${i}`)
.option('host', 'http://localhost:18000')
.attrs({
is_staff: true,
original_user_is_staff: false,
number: 'DemoX',
org: 'edX',
verified_mode: {
upgrade_url: 'test',
price: 10,
currency_symbol: '$',
},
})
.attr('tabs', ['id', 'host'], (id, host) => {
const tabs = [
Factory.build(
'tab',
{
title: 'Course',
slug: 'courseware',
type: 'courseware',
},
{ courseId: id, host, path: 'course/' },
),
Factory.build(
'tab',
{
title: 'Discussion',
slug: 'discussion',
type: 'discussion',
},
{ courseId: id, host, path: 'discussion/forum/' },
),
Factory.build(
'tab',
{
title: 'Wiki',
slug: 'wiki',
type: 'wiki',
},
{ courseId: id, host, path: 'course_wiki' },
),
Factory.build(
'tab',
{
title: 'Progress',
slug: 'progress',
type: 'progress',
},
{ courseId: id, host, path: 'progress' },
),
Factory.build(
'tab',
{
title: 'Instructor',
slug: 'instructor',
type: 'instructor',
},
{ courseId: id, host, path: 'instructor' },
),
Factory.build(
'tab',
{
title: 'Dates',
slug: 'dates',
type: 'dates',
},
{ courseId: id, host, path: 'dates' },
),
];
return tabs;
});

View File

@@ -0,0 +1,12 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('tab')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('path', 'course/')
.option('host', 'http://localhost:18000')
.attrs({
title: 'Course',
slug: 'courseware',
})
.attr('tab_id', ['slug'], slug => slug)
.attr('url', ['courseId', 'path', 'host'], (courseId, path, host) => `${host}/courses/${courseId}/${path}`);

View File

@@ -0,0 +1,42 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { Factory } from 'rosie';
import { render, screen } from '../../../setupTest';
import BulkEmailTool from '../BulkEmailTool';
import { getCourseHomeCourseMetadata } from '../data/api';
import '../data/__factories__/courseMetadata.factory';
jest.mock('../text-editor/TextEditor');
jest.mock('../bulk-email-task-manager/api', () => ({
getInstructorTasks: jest.fn(() => ({ tasks: {} })),
getEmailTaskHistory: jest.fn(() => ({ tasks: {} })),
}));
jest.mock('../data/api', () => ({
__esModule: true,
getCourseHomeCourseMetadata: jest.fn(() => {}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(() => ({
courseId: 'test-course-id',
})),
}));
describe('BulkEmailTool', () => {
test('BulkEmailTool renders properly when given course metadata', async () => {
const courseMetadata = Factory.build('courseMetadata');
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
render(<BulkEmailTool />);
expect(await screen.findByText('Course')).toBeTruthy();
});
test('BulkEmailTool renders error page on no staff user', async () => {
const courseMetadata = Factory.build('courseMetadata', { is_staff: false });
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
render(<BulkEmailTool />);
expect(
await screen.findByText('An unexpected error occurred. Please click the button below to refresh the page.'),
).toBeTruthy();
});
});

View File

@@ -13,7 +13,7 @@ import 'tinymce/plugins/emoticons/js/emojis';
import 'tinymce/plugins/link';
import 'tinymce/plugins/lists';
import 'tinymce/plugins/table';
import 'tinymce-language-selector';
import '@edx/tinymce-language-selector';
import contentUiCss from 'tinymce/skins/ui/oxide/content.css';
import contentCss from 'tinymce/skins/content/default/content.css';

View File

@@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
function MockTinyMCE({ onInit }) {
const mockedEditor = {
getContent: () => 'test body',
};
onInit({}, mockedEditor);
return <div />;
}
MockTinyMCE.propTypes = {
onInit: PropTypes.func.isRequired,
};
export default function TextEditor({ onInit }) {
return <MockTinyMCE onInit={onInit} />;
}
TextEditor.propTypes = {
onInit: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1 @@
export { default } from './TextEditor';

View File

@@ -11,7 +11,7 @@ export default function NavigationTabs(props) {
<Nav>
{tabData && tabData.map(tab => (
<Nav.Item key={tab.tab_id}>
<Nav.Link eventKey={tab.tab_id} href={tab.url} className="mx-3 py-2">{tab.title}</Nav.Link>
<Nav.Link eventKey={tab.url} href={tab.url} className="mx-3 py-2">{tab.title}</Nav.Link>
</Nav.Item>
))}
</Nav>
@@ -20,7 +20,7 @@ export default function NavigationTabs(props) {
}
NavigationTabs.propTypes = {
tabData: PropTypes.arrayOf(PropTypes.exact({
tabData: PropTypes.arrayOf(PropTypes.shape({
tab_id: PropTypes.string,
title: PropTypes.string,
url: PropTypes.string,

View File

@@ -1,5 +1,6 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom';
import React from 'react';
import PropTypes from 'prop-types';
import { render } from '@testing-library/react';
@@ -50,9 +51,7 @@ configureI18n({
function Wrapper({ children }) {
return (
// eslint-disable-next-line react/jsx-filename-extension
<AppProvider>
{children}
</AppProvider>
<AppProvider>{children}</AppProvider>
);
}
@@ -67,6 +66,4 @@ Wrapper.propTypes = {
export * from '@testing-library/react';
// Override `render` method.
export {
renderWithProviders as render,
};
export { renderWithProviders as render };

28
src/utils/useTimeout.js Normal file
View File

@@ -0,0 +1,28 @@
import { useRef, useEffect } from 'react';
/**
* React hook to delay a function being called by a given delay amount.
* Works by creating a ref to a setTimeout() function call to the DOM and
* waiting for the timer to end before firing the callback. if multiple
* timouts are made at once, the hook will remove all previous timeouts
* and only allow one at a time.
* @param {function} callback The function to call once the delay ends
* @param {millisecond} delay time to delay function call
*/
export default function useTimeout(callback, delay) {
const timeoutRef = useRef(null);
useEffect(() => {
const timeout = timeoutRef.current;
if (timeout) {
clearTimeout(timeout);
}
}, []);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(callback, delay);
};
}