feat: [MICROBA-1528] add schedule email UI (#36)
This handles a few things around the scheduled email UI. This includes: 1. Adding the schedule email UI date/time picker. 2. Adds states to the submit button for scheduling emails. 3. Drys out intl code and some submitting states. 4. Matches the email form UI to mocks. This however does not include: 1. A table to show scheduled emails. Scheduled emails at the moment are displayed in the "pending tasks" section. 2. Matching the tasks section to the mocks.
This commit is contained in:
1
.env
1
.env
@@ -18,3 +18,4 @@ REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
SCHEDULE_EMAIL_SECTION=''
|
||||
|
||||
@@ -19,3 +19,4 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=localhost
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SCHEDULE_EMAIL_SECTION='true'
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import React, { useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import BulkEmailTaskManager from './bulk-email-task-manager/BulkEmailTaskManager';
|
||||
import NavigationTabs from '../navigation-tabs/NavigationTabs';
|
||||
import useMobileResponsive from '../../utils/useMobileResponsive';
|
||||
import BulkEmailForm from './bulk-email-form';
|
||||
import { CourseMetadataContext } from '../page-container/PageContainer';
|
||||
|
||||
export default function BulkEmailTool() {
|
||||
const { courseId } = useParams();
|
||||
|
||||
const isMobile = useMobileResponsive();
|
||||
const textEditorRef = useRef();
|
||||
|
||||
const copyTextToEditor = (body) => {
|
||||
if (textEditorRef?.current) {
|
||||
textEditorRef.current.setContent(body);
|
||||
@@ -27,11 +22,11 @@ export default function BulkEmailTool() {
|
||||
{(courseMetadata) => (courseMetadata.isStaff ? (
|
||||
<div>
|
||||
<NavigationTabs courseId={courseId} tabData={courseMetadata.tabs} />
|
||||
<div className={classnames({ 'border border-primary-200': !isMobile })}>
|
||||
<div>
|
||||
<div className="row">
|
||||
<BulkEmailForm courseId={courseId} cohorts={courseMetadata.cohorts} editorRef={textEditorRef} />
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="row py-5">
|
||||
<BulkEmailTaskManager courseId={courseId} copyTextToEditor={copyTextToEditor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,57 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, 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 {
|
||||
SpinnerSimple, Cancel, Send, Event, Check,
|
||||
} from '@edx/paragon/icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import TextEditor from '../text-editor/TextEditor';
|
||||
import { postBulkEmail } from './api';
|
||||
import BulkEmailRecipient from './bulk-email-recipient';
|
||||
import TaskAlertModal from './TaskAlertModal';
|
||||
import useTimeout from '../../../utils/useTimeout';
|
||||
import useMobileResponsive from '../../../utils/useMobileResponsive';
|
||||
import ScheduleEmailForm from './ScheduleEmailForm';
|
||||
import messages from './messages';
|
||||
|
||||
export const FORM_SUBMIT_STATES = {
|
||||
DEFAULT: 'default',
|
||||
PENDING: 'pending',
|
||||
COMPLETE: 'complete',
|
||||
COMPLETED_DEFAULT: 'completed_default',
|
||||
COMPLETE_SCHEDULE: 'completed_schedule',
|
||||
SCHEDULE: 'schedule',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
export default function BulkEmailForm(props) {
|
||||
const { courseId, cohorts, editorRef } = props;
|
||||
function BulkEmailForm(props) {
|
||||
const {
|
||||
courseId, cohorts, editorRef, intl,
|
||||
} = props;
|
||||
const [subject, setSubject] = useState('');
|
||||
const [dateTime, setDateTime] = useState({ scheduleDate: '', scheduleTime: '' });
|
||||
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,
|
||||
schedule: true,
|
||||
});
|
||||
const [selectedRecipients, { add, remove }] = useCheckboxSetValues([]);
|
||||
const [isTaskAlertOpen, openTaskAlert, closeTaskAlert] = useToggle(false);
|
||||
const [isScheduled, toggleScheduled] = useState(false);
|
||||
const resetEmailForm = useTimeout(() => {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETED_DEFAULT);
|
||||
if (isScheduled) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.SCHEDULE);
|
||||
} else {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.DEFAULT);
|
||||
}
|
||||
}, 3000);
|
||||
const isMobile = useMobileResponsive();
|
||||
|
||||
const onRecipientChange = (event) => {
|
||||
if (event.target.checked) {
|
||||
@@ -47,19 +65,31 @@ export default function BulkEmailForm(props) {
|
||||
};
|
||||
|
||||
const onSubjectChange = (event) => setSubject(event.target.value);
|
||||
const onDateTimeChange = (event) => {
|
||||
const next = { [event.target.name]: event.target.value };
|
||||
setDateTime((prev) => ({ ...prev, ...next }));
|
||||
};
|
||||
|
||||
const validateDateTime = (date, time) => {
|
||||
if (isScheduled) {
|
||||
return !!date && !!time;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateEmailForm = () => {
|
||||
const subjectValid = subject.length !== 0;
|
||||
const bodyValid = editorRef.current.getContent().length !== 0;
|
||||
const recipientsValid = selectedRecipients.length !== 0;
|
||||
|
||||
const { scheduleDate, scheduleTime } = dateTime;
|
||||
const scheduleValid = validateDateTime(scheduleDate, scheduleTime);
|
||||
setEmailFormValidation({
|
||||
subject: subjectValid,
|
||||
recipients: recipientsValid,
|
||||
body: bodyValid,
|
||||
schedule: scheduleValid,
|
||||
});
|
||||
|
||||
return subjectValid && bodyValid && recipientsValid;
|
||||
return subjectValid && bodyValid && recipientsValid && scheduleValid;
|
||||
};
|
||||
|
||||
const createEmailTask = async () => {
|
||||
@@ -70,6 +100,11 @@ export default function BulkEmailForm(props) {
|
||||
emailData.append('send_to', JSON.stringify(selectedRecipients));
|
||||
emailData.append('subject', subject);
|
||||
emailData.append('message', editorRef.current.getContent());
|
||||
if (isScheduled) {
|
||||
const { scheduleDate, scheduleTime } = dateTime;
|
||||
emailData.append('schedule', new Date(`${scheduleDate} ${scheduleTime}`).toISOString());
|
||||
emailData.append('browser_timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await postBulkEmail(emailData, courseId);
|
||||
@@ -78,33 +113,42 @@ export default function BulkEmailForm(props) {
|
||||
return;
|
||||
}
|
||||
if (data.status === 200) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE);
|
||||
if (isScheduled) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE_SCHEDULE);
|
||||
} else {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE);
|
||||
}
|
||||
resetEmailForm();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isScheduled) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.SCHEDULE);
|
||||
} else {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.DEFAULT);
|
||||
}
|
||||
}, [isScheduled]);
|
||||
|
||||
return (
|
||||
<div className="w-100 m-auto p-lg-4 py-2.5 px-5">
|
||||
<div className={classNames('w-100 m-auto p-lg-4 py-2.5', !isMobile && 'px-5 border border-primary-200')}>
|
||||
<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>
|
||||
<p>{intl.formatMessage(messages.bulkEmailTaskAlertRecipients, { subject })}</p>
|
||||
<ul className="list-unstyled">
|
||||
{selectedRecipients.map((group) => (
|
||||
<li key={group}>{group}</li>
|
||||
))}
|
||||
</ul>
|
||||
{!isScheduled && (
|
||||
<p>
|
||||
<strong>{intl.formatMessage(messages.bulkEmailInstructionsCaution)}</strong>
|
||||
{intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
close={(event) => {
|
||||
@@ -115,13 +159,7 @@ export default function BulkEmailForm(props) {
|
||||
}}
|
||||
/>
|
||||
<Form>
|
||||
<p className="h2">
|
||||
<FormattedMessage
|
||||
id="bulk.email.tool.label"
|
||||
defaultMessage="Email"
|
||||
description="Tool label. Describes the function of the tool (to send email)."
|
||||
/>
|
||||
</p>
|
||||
<p className="h2">{intl.formatMessage(messages.bulkEmailToolLabel)}</p>
|
||||
<BulkEmailRecipient
|
||||
selectedGroups={selectedRecipients}
|
||||
handleCheckboxes={onRecipientChange}
|
||||
@@ -129,113 +167,90 @@ export default function BulkEmailForm(props) {
|
||||
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.Label>{intl.formatMessage(messages.bulkEmailSubjectLabel)}</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."
|
||||
/>
|
||||
{intl.formatMessage(messages.bulkEmailFormSubjectError)}
|
||||
</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>
|
||||
<Form.Label>{intl.formatMessage(messages.bulkEmailBodyLabel)}</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."
|
||||
/>
|
||||
{intl.formatMessage(messages.bulkEmailFormBodyError)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="bulk.email.instructions.proofreading"
|
||||
defaultMessage="We recommend sending learners no more than one email message per week. Before you send your email, review
|
||||
the text carefully and send it to yourself first, so that you can preview the formatting and make sure
|
||||
embedded images and links work correctly."
|
||||
description="A set of instructions to give users a heads up about the formatting of the email they are about to send"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<strong>
|
||||
<FormattedMessage id="bulk.email.instructions.caution" defaultMessage="Caution!" />
|
||||
</strong>
|
||||
<FormattedMessage
|
||||
id="bulk.email.instructions.caution.message"
|
||||
defaultMessage=" When you select Send Email, your email message is added to the queue for sending,
|
||||
and cannot be cancelled."
|
||||
description="A warning about how emails are sent out to users"
|
||||
/>
|
||||
</p>
|
||||
<p>{intl.formatMessage(messages.bulkEmailInstructionsProofreading)}</p>
|
||||
</div>
|
||||
<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"
|
||||
<Form.Group>
|
||||
{getConfig().SCHEDULE_EMAIL_SECTION && (
|
||||
<div className="mb-3">
|
||||
<Form.Checkbox
|
||||
name="scheduleEmailBox"
|
||||
checked={isScheduled}
|
||||
onChange={() => toggleScheduled((prev) => !prev)}
|
||||
disabled={emailFormStatus === FORM_SUBMIT_STATES.PENDING}
|
||||
>
|
||||
{intl.formatMessage(messages.bulkEmailFormScheduleBox)}
|
||||
</Form.Checkbox>
|
||||
</div>
|
||||
)}
|
||||
{isScheduled && (
|
||||
<ScheduleEmailForm
|
||||
isValid={emailFormValidation.schedule}
|
||||
onDateTimeChange={onDateTimeChange}
|
||||
dateTime={dateTime}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<div
|
||||
className={classNames('d-flex', {
|
||||
'mt-n4.5': !isScheduled && !isMobile,
|
||||
'flex-row-reverse justify-content-between align-items-end': !isMobile,
|
||||
'border-top pt-2': isScheduled,
|
||||
})}
|
||||
>
|
||||
<StatefulButton
|
||||
variant="primary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
openTaskAlert();
|
||||
}}
|
||||
state={emailFormStatus}
|
||||
icons={{
|
||||
[FORM_SUBMIT_STATES.DEFAULT]: <Icon src={Send} />,
|
||||
[FORM_SUBMIT_STATES.SCHEDULE]: <Icon src={Event} />,
|
||||
[FORM_SUBMIT_STATES.PENDING]: <Icon src={SpinnerSimple} className="icon-spin" />,
|
||||
[FORM_SUBMIT_STATES.COMPLETE]: <Icon src={Check} />,
|
||||
[FORM_SUBMIT_STATES.COMPLETE_SCHEDULE]: <Icon src={Check} />,
|
||||
[FORM_SUBMIT_STATES.ERROR]: <Icon src={Cancel} />,
|
||||
}}
|
||||
labels={{
|
||||
[FORM_SUBMIT_STATES.DEFAULT]: intl.formatMessage(messages.bulkEmailSubmitButtonDefault),
|
||||
[FORM_SUBMIT_STATES.SCHEDULE]: intl.formatMessage(messages.bulkEmailSubmitButtonSchedule),
|
||||
[FORM_SUBMIT_STATES.PENDING]: intl.formatMessage(messages.bulkEmailSubmitButtonPending),
|
||||
[FORM_SUBMIT_STATES.COMPLETE]: intl.formatMessage(messages.bulkEmailSubmitButtonComplete),
|
||||
[FORM_SUBMIT_STATES.COMPLETE_SCHEDULE]: intl.formatMessage(
|
||||
messages.bulkEmailSubmitButtonCompleteSchedule,
|
||||
),
|
||||
[FORM_SUBMIT_STATES.ERROR]: intl.formatMessage(messages.bulkEmailSubmitButtonError),
|
||||
}}
|
||||
disabledStates={[
|
||||
FORM_SUBMIT_STATES.PENDING,
|
||||
FORM_SUBMIT_STATES.COMPLETE,
|
||||
FORM_SUBMIT_STATES.COMPLETE_SCHEDULE,
|
||||
]}
|
||||
/>
|
||||
{emailFormStatus === FORM_SUBMIT_STATES.ERROR && (
|
||||
<Form.Control.Feedback hasIcon={false} type="invalid">
|
||||
{intl.formatMessage(messages.bulkEmailFormError)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</div>
|
||||
@@ -251,4 +266,7 @@ BulkEmailForm.propTypes = {
|
||||
cohorts: PropTypes.arrayOf(PropTypes.string),
|
||||
editorRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(Element) })])
|
||||
.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BulkEmailForm);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Form, Icon } from '@edx/paragon';
|
||||
import { Event, WatchOutline } from '@edx/paragon/icons';
|
||||
import useMobileResponsive from '../../../utils/useMobileResponsive';
|
||||
|
||||
function ScheduleEmailForm(props) {
|
||||
const isMobile = useMobileResponsive();
|
||||
const { isValid, onDateTimeChange, dateTime } = props;
|
||||
const { scheduleDate, scheduleTime } = dateTime;
|
||||
return (
|
||||
<Form.Group>
|
||||
<div className={classNames('d-flex', isMobile ? 'flex-column' : 'flex-row', 'my-3')}>
|
||||
<div className="w-lg-25 mx-2">
|
||||
<Form.Label>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.schedule.date"
|
||||
defaultMessage="Date"
|
||||
description="Label for the date portion of the email schedule form"
|
||||
/>
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="date"
|
||||
trailingElement={<Icon src={Event} />}
|
||||
name="scheduleDate"
|
||||
data-testid="scheduleDate"
|
||||
onChange={onDateTimeChange}
|
||||
value={scheduleDate}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-lg-25 mx-2">
|
||||
<Form.Label>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.schedule.time"
|
||||
defaultMessage="Time"
|
||||
description="Label for the time portion of the email schedule form"
|
||||
/>
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="time"
|
||||
trailingElement={<Icon src={WatchOutline} />}
|
||||
name="scheduleTime"
|
||||
data-testid="scheduleTime"
|
||||
onChange={onDateTimeChange}
|
||||
value={scheduleTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isValid && (
|
||||
<Form.Control.Feedback className="pb-2" hasIcon type="invalid">
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.dateTime.error"
|
||||
defaultMessage="Date and time cannot be blank"
|
||||
description="An error message located under the date-time selector. Visible only on failure."
|
||||
/>
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
ScheduleEmailForm.defaultProps = {
|
||||
dateTime: {
|
||||
scheduleDate: '',
|
||||
scheduleTime: '',
|
||||
},
|
||||
};
|
||||
|
||||
ScheduleEmailForm.propTypes = {
|
||||
isValid: PropTypes.bool.isRequired,
|
||||
onDateTimeChange: PropTypes.func.isRequired,
|
||||
dateTime: PropTypes.shape({
|
||||
scheduleDate: PropTypes.string,
|
||||
scheduleTime: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
export default ScheduleEmailForm;
|
||||
89
src/components/bulk-email-tool/bulk-email-form/messages.js
Normal file
89
src/components/bulk-email-tool/bulk-email-form/messages.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
/* BulkEmailForm.jsx Messages */
|
||||
bulkEmailSubmitButtonDefault: {
|
||||
id: 'bulk.email.submit.button.default',
|
||||
defaultMessage: 'Send Email',
|
||||
},
|
||||
bulkEmailSubmitButtonSchedule: {
|
||||
id: 'bulk.email.submit.button.schedule',
|
||||
defaultMessage: 'Schedule Email',
|
||||
},
|
||||
bulkEmailSubmitButtonPending: {
|
||||
id: 'bulk.email.submit.button.pending',
|
||||
defaultMessage: 'Submitting',
|
||||
},
|
||||
bulkEmailSubmitButtonComplete: {
|
||||
id: 'bulk.email.submit.button.send.complete',
|
||||
defaultMessage: 'Email Created',
|
||||
},
|
||||
bulkEmailSubmitButtonError: {
|
||||
id: 'bulk.email.submit.button.error',
|
||||
defaultMessage: 'Error',
|
||||
},
|
||||
bulkEmailSubmitButtonCompleteSchedule: {
|
||||
id: 'bulk.email.submit.button.schedule.complete',
|
||||
defaultMessage: 'Scheduling Done',
|
||||
},
|
||||
bulkEmailTaskAlertRecipients: {
|
||||
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.',
|
||||
},
|
||||
bulkEmailToolLabel: {
|
||||
id: 'bulk.email.tool.label',
|
||||
defaultMessage: 'Email',
|
||||
description: 'Tool label. Describes the function of the tool (to send email).',
|
||||
},
|
||||
bulkEmailSubjectLabel: {
|
||||
id: 'bulk.email.subject.label',
|
||||
defaultMessage: 'Subject:',
|
||||
description: 'Email subject line input label. Meant to have colon or equivilant punctuation.',
|
||||
},
|
||||
bulkEmailFormSubjectError: {
|
||||
id: 'bulk.email.form.subject.error',
|
||||
defaultMessage: 'A subject is required',
|
||||
description: 'An Error message located under the subject line. Visible only on failure.',
|
||||
},
|
||||
bulkEmailBodyLabel: {
|
||||
id: 'bulk.email.body.label',
|
||||
defaultMessage: 'Body:',
|
||||
description: 'Email Body label. Meant to have colon or equivilant punctuation.',
|
||||
},
|
||||
bulkEmailFormBodyError: {
|
||||
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.',
|
||||
},
|
||||
bulkEmailInstructionsProofreading: {
|
||||
id: 'bulk.email.instructions.proofreading',
|
||||
defaultMessage: 'We recommend sending learners no more than one email message per week. Before you send your email, review the text carefully and send it to yourself first, so that you can preview the formatting and make sure embedded images and links work correctly.',
|
||||
description: 'A set of instructions to give users a heads up about the formatting of the email they are about to send',
|
||||
},
|
||||
bulkEmailInstructionsCaution: { id: 'bulk.email.instructions.caution', defaultMessage: 'Caution!' },
|
||||
|
||||
bulkEmailInstructionsCautionMessage: {
|
||||
id: 'bulk.email.instructions.caution.message',
|
||||
defaultMessage:
|
||||
' When you select Send Email, your email message is added to the queue for sending, and cannot be cancelled.',
|
||||
description: 'A warning about how emails are sent out to users',
|
||||
},
|
||||
bulkEmailFormScheduleBox: {
|
||||
id: 'bulk.email.form.scheduleBox',
|
||||
defaultMessage: 'Schedule this email for a future date',
|
||||
description: 'Checkbox to schedule sending the email at a later date',
|
||||
},
|
||||
bulkEmailSendEmailButton: {
|
||||
id: 'bulk.email.send.email.button',
|
||||
defaultMessage: 'Send Email',
|
||||
description: 'Schedule/Send email button',
|
||||
},
|
||||
bulkEmailFormError: {
|
||||
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.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
render, screen, cleanup, act, fireEvent,
|
||||
render, screen, cleanup, fireEvent, initializeMockApp,
|
||||
} from '../../../../setupTest';
|
||||
import BulkEmailForm from '..';
|
||||
import { postBulkEmail } from '../api';
|
||||
@@ -13,23 +13,28 @@ jest.mock('../api', () => ({
|
||||
__esModule: true,
|
||||
postBulkEmail: jest.fn(() => ({ status: 200 })),
|
||||
}));
|
||||
const appendMock = jest.spyOn(FormData.prototype, 'append');
|
||||
|
||||
describe('bulk-email-form', () => {
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
|
||||
beforeEach(() => jest.resetModules());
|
||||
afterEach(cleanup);
|
||||
test('it renders', () => {
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
expect(screen.getByText('Submit')).toBeTruthy();
|
||||
expect(screen.getByText('Send Email')).toBeTruthy();
|
||||
});
|
||||
test('it shows a warning when clicking submit', async () => {
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
fireEvent.click(screen.getByText('Send Email'));
|
||||
const warning = await screen.findByText('CAUTION!', { exact: false });
|
||||
expect(warning).toBeTruthy();
|
||||
});
|
||||
test('Prevent form POST if invalid', async () => {
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
fireEvent.click(screen.getByText('Send Email'));
|
||||
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();
|
||||
@@ -40,26 +45,59 @@ describe('bulk-email-form', () => {
|
||||
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'));
|
||||
fireEvent.click(screen.getByText('Send Email'));
|
||||
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();
|
||||
expect(await screen.findByText('Email 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" editorRef={jest.fn()} />);
|
||||
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();
|
||||
});
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
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('Send Email'));
|
||||
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
test('Shows scheduling form when checkbox is checked and submit is changed', async () => {
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
const scheduleCheckbox = screen.getByText('Schedule this email for a future date');
|
||||
fireEvent.click(scheduleCheckbox);
|
||||
expect(screen.getByText('Time'));
|
||||
expect(screen.getByText('Date'));
|
||||
expect(screen.getByText('Schedule Email'));
|
||||
});
|
||||
test('Prevents sending email when scheduling inputs are empty', async () => {
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
const scheduleCheckbox = screen.getByText('Schedule this email for a future date');
|
||||
fireEvent.click(scheduleCheckbox);
|
||||
const submitButton = await screen.findByText('Schedule Email');
|
||||
fireEvent.click(submitButton);
|
||||
const continueButton = await screen.findByRole('button', { name: /continue/i });
|
||||
fireEvent.click(continueButton);
|
||||
expect(screen.getByText('Date and time cannot be blank'));
|
||||
});
|
||||
test('Adds scheduling data to POST requests when schedule is selected', async () => {
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Myself' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Subject:' }), { target: { value: 'test subject' } });
|
||||
const scheduleCheckbox = screen.getByText('Schedule this email for a future date');
|
||||
fireEvent.click(scheduleCheckbox);
|
||||
const submitButton = screen.getByText('Schedule Email');
|
||||
const scheduleDate = screen.getByTestId('scheduleDate');
|
||||
const scheduleTime = screen.getByTestId('scheduleTime');
|
||||
fireEvent.change(scheduleDate, { target: { value: '2020-05-24' } });
|
||||
fireEvent.change(scheduleTime, { target: { value: '10:00' } });
|
||||
fireEvent.click(submitButton);
|
||||
const continueButton = await screen.findByRole('button', { name: /continue/i });
|
||||
fireEvent.click(continueButton);
|
||||
expect(appendMock).toHaveBeenCalledWith('schedule', expect.stringContaining('2020-05-24'));
|
||||
expect(postBulkEmail).toHaveBeenCalledWith(expect.any(FormData), expect.stringContaining('test-course-id'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import messages from './messages';
|
||||
|
||||
function BulkEmailTaskManager({ intl, copyTextToEditor }) {
|
||||
return (
|
||||
<div className="px-5">
|
||||
<div>
|
||||
<div>
|
||||
<h2 className="h3">
|
||||
{intl.formatMessage(messages.pendingTasksHeader)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
render, screen, fireEvent, cleanup, act,
|
||||
render, screen, fireEvent, cleanup, act, initializeMockApp,
|
||||
} from '../../../../setupTest';
|
||||
import BulkEmailContentHistory from '../BulkEmailContentHistory';
|
||||
import { getSentEmailHistory } from '../data/api';
|
||||
@@ -16,6 +16,9 @@ jest.mock('../data/api', () => ({
|
||||
|
||||
describe('BulkEmailContentHistory component', () => {
|
||||
beforeEach(() => jest.resetModules());
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders correctly', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
render, screen, cleanup, act,
|
||||
render, screen, cleanup, act, initializeMockApp,
|
||||
} from '../../../../setupTest';
|
||||
import BulkEmailPendingTasks from '../BulkEmailPendingTasks';
|
||||
import { getInstructorTasks } from '../data/api';
|
||||
@@ -16,6 +16,9 @@ jest.mock('../data/api', () => ({
|
||||
|
||||
describe('BulkEmailPendingTasks component', () => {
|
||||
beforeEach(() => jest.resetModules());
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders correctly', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
render, screen, fireEvent, cleanup, act,
|
||||
render, screen, fireEvent, cleanup, act, initializeMockApp,
|
||||
} from '../../../../setupTest';
|
||||
import BulkEmailTaskHistory from '../BulkEmailTaskHistory';
|
||||
import { getEmailTaskHistory } from '../data/api';
|
||||
@@ -16,6 +16,9 @@ jest.mock('../data/api', () => ({
|
||||
|
||||
describe('BulkEmailTaskHistory component', () => {
|
||||
beforeEach(() => jest.resetModules());
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders correctly ', async () => {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { render, screen, cleanup } from '../../../setupTest';
|
||||
import {
|
||||
render, screen, cleanup, initializeMockApp,
|
||||
} from '../../../setupTest';
|
||||
import BulkEmailTool from '../BulkEmailTool';
|
||||
import { CourseMetadataContext } from '../../page-container/PageContainer';
|
||||
import '../../page-container/data/__factories__/cohort.factory';
|
||||
@@ -23,6 +25,9 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
describe('BulkEmailTool', () => {
|
||||
beforeEach(() => jest.resetModules());
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
act, cleanup, render, screen,
|
||||
act, cleanup, initializeMockApp, render, screen,
|
||||
} from '../../../setupTest';
|
||||
|
||||
import PageContainer from '../PageContainer';
|
||||
@@ -26,6 +26,9 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
describe('PageContainer', () => {
|
||||
beforeEach(() => jest.resetModules());
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('PageContainer renders properly when given course metadata', async () => {
|
||||
|
||||
@@ -39,13 +39,16 @@ subscribe(APP_INIT_ERROR, (error) => {
|
||||
});
|
||||
|
||||
initialize({
|
||||
config: () => {
|
||||
mergeConfig({
|
||||
}, 'CommuncationsAppConfig');
|
||||
handlers: {
|
||||
config: () => {
|
||||
mergeConfig(
|
||||
{
|
||||
// MICROBA-1505: Remove this when we remove the flag from config
|
||||
SCHEDULE_EMAIL_SECTION: process.env.SCHEDULE_EMAIL_SECTION || null,
|
||||
},
|
||||
'CommunicationsAppConfig',
|
||||
);
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
appMessages,
|
||||
headerMessages,
|
||||
footerMessages,
|
||||
],
|
||||
messages: [appMessages, headerMessages, footerMessages],
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@import "@edx/brand/paragon/fonts.scss";
|
||||
@import "@edx/brand/paragon/variables.scss";
|
||||
@import "@edx/paragon/scss/core/core.scss";
|
||||
@import "@edx/brand/paragon/overrides.scss";
|
||||
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
@import "~@edx/frontend-component-footer/dist/footer";
|
||||
|
||||
@@ -2,17 +2,16 @@ 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';
|
||||
import { render as rtlRender } from '@testing-library/react';
|
||||
import AppProvider from '@edx/frontend-platform/react/AppProvider';
|
||||
import { configure as configureI18n } from '@edx/frontend-platform/i18n';
|
||||
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { configure as configureLogging } from '@edx/frontend-platform/logging';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import appMessages from './i18n';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
@@ -38,32 +37,40 @@ class MockLoggingService {
|
||||
logError = jest.fn();
|
||||
}
|
||||
|
||||
const loggingService = configureLogging(MockLoggingService, {
|
||||
config: getConfig(),
|
||||
});
|
||||
export function initializeMockApp() {
|
||||
mergeConfig({
|
||||
// MICROBA-1505: Remove this when we remove the flag from config
|
||||
SCHEDULE_EMAIL_SECTION: true,
|
||||
});
|
||||
|
||||
configureI18n({
|
||||
config: getConfig(),
|
||||
loggingService,
|
||||
messages: [appMessages],
|
||||
});
|
||||
const loggingService = configureLogging(MockLoggingService, {
|
||||
config: getConfig(),
|
||||
});
|
||||
|
||||
function Wrapper({ children }) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-filename-extension
|
||||
<AppProvider>{children}</AppProvider>
|
||||
);
|
||||
configureI18n({
|
||||
config: getConfig(),
|
||||
loggingService,
|
||||
messages: [appMessages],
|
||||
});
|
||||
|
||||
return { loggingService };
|
||||
}
|
||||
|
||||
const renderWithProviders = (ui, options) => {
|
||||
render(ui, { wrapper: Wrapper, ...options });
|
||||
};
|
||||
Wrapper.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
function render(ui, options) {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
function Wrapper({ children }) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-filename-extension
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
return rtlRender(ui, { wrapper: Wrapper, ...options });
|
||||
}
|
||||
|
||||
// Re-export everything.
|
||||
export * from '@testing-library/react';
|
||||
|
||||
// Override `render` method.
|
||||
export { renderWithProviders as render };
|
||||
export { render };
|
||||
|
||||
Reference in New Issue
Block a user