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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function BulkEmailRecepient() {
|
||||
return <div />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
225
src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx
Normal file
225
src/components/bulk-email-tool/bulk-email-form/BulkEmailForm.jsx
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
8
src/components/bulk-email-tool/bulk-email-form/api.js
Normal file
8
src/components/bulk-email-tool/bulk-email-form/api.js
Normal 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);
|
||||
}
|
||||
1
src/components/bulk-email-tool/bulk-email-form/index.js
Normal file
1
src/components/bulk-email-tool/bulk-email-form/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './BulkEmailForm';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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}`);
|
||||
42
src/components/bulk-email-tool/test/BulkEmailTool.test.jsx
Normal file
42
src/components/bulk-email-tool/test/BulkEmailTool.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
1
src/components/bulk-email-tool/text-editor/index.js
Normal file
1
src/components/bulk-email-tool/text-editor/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './TextEditor';
|
||||
@@ -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,
|
||||
|
||||
@@ -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
28
src/utils/useTimeout.js
Normal 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user