Compare commits

...

7 Commits

Author SHA1 Message Date
Eugene Dyudyunov
41bd035132 fix: limit email subject length
Add limitation for subject length (128 chars) and the
corresponding help text.
2024-02-28 09:30:45 -03:00
Eugene Dyudyunov
eae2037d20 fix: hardcoded course mode checkboxes (#192)
* fix: Course mode is used to show the correct bulk email options

* fix: Logical operations moved into a separate variable

* refactor: apply review suggestions

---------

Co-authored-by: Taras Lytvynenko <69678257+Inferato@users.noreply.github.com>
2024-02-27 15:16:27 -03:00
Omar Al-Ithawi
39c18ac4fa feat!: remove broken transifex and use atlas exclusively | FC-0012 (#164)
* feat!: remove broken transifex and use atlas exclusively

* feat: install openedx-atlas
2023-11-17 13:27:02 -03:00
Ihor Romaniuk
a4b7b5f28d fix: date format depends on locale date format (#174) 2023-11-16 07:22:43 -03:00
Stanislav Lunyachek
42797240df fix: Missed favicon in Safari 2023-11-14 12:37:04 -03:00
vladislavkeblysh
ce1e7d040e feat: fixed layout 2023-11-14 12:33:34 -03:00
Syed Ali Abbas Zaidi
3e7ea6a3e5 chore: bump frontend-platform (#158) 2023-10-18 11:13:23 +05:00
15 changed files with 144 additions and 93 deletions

View File

@@ -1,8 +0,0 @@
[main]
host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-communications]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

View File

@@ -1,6 +1,3 @@
export TRANSIFEX_RESOURCE = frontend-app-communications
transifex_langs = "ar,fr,es_419,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n i18n = ./src/i18n
@@ -32,35 +29,17 @@ detect_changed_source_translations:
# Checking for changed translations... # Checking for changed translations...
git diff --exit-code $(i18n) git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations: pull_translations:
rm -rf src/i18n/messages rm -rf src/i18n/messages
mkdir src/i18n/messages mkdir src/i18n/messages
cd src/i18n/messages \ cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \ && atlas pull \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \ translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/paragon/src/i18n/messages:paragon \ translations/paragon/src/i18n/messages:paragon \
translations/frontend-app-communications/src/i18n/messages:frontend-app-communications translations/frontend-app-communications/src/i18n/messages:frontend-app-communications
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-app-communications $(intl_imports) frontend-component-header frontend-component-footer paragon frontend-app-communications
endif
# This target is used by Travis. # This target is used by Travis.
validate-no-uncommitted-package-lock-changes: validate-no-uncommitted-package-lock-changes:

18
package-lock.json generated
View File

@@ -12,7 +12,8 @@
"@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.2.0", "@edx/frontend-component-footer": "12.2.0",
"@edx/frontend-component-header": "4.6.0", "@edx/frontend-component-header": "4.6.0",
"@edx/frontend-platform": "5.0.0", "@edx/frontend-platform": "5.5.2",
"@edx/openedx-atlas": "^0.5.0",
"@edx/paragon": "^20.44.0", "@edx/paragon": "^20.44.0",
"@edx/tinymce-language-selector": "1.1.0", "@edx/tinymce-language-selector": "1.1.0",
"@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/fontawesome-svg-core": "1.2.36",
@@ -3020,8 +3021,9 @@
} }
}, },
"node_modules/@edx/frontend-platform": { "node_modules/@edx/frontend-platform": {
"version": "5.0.0", "version": "5.5.2",
"license": "AGPL-3.0", "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-5.5.2.tgz",
"integrity": "sha512-cbUvWcFL/mTc7eypBS/BnCojgWDcJCe3h3ffb3GD7F+Y4ysrFBJYf031qPcgmWNUrN30452dR7r1+sqE7uVvYA==",
"dependencies": { "dependencies": {
"@cospired/i18n-iso-languages": "4.1.0", "@cospired/i18n-iso-languages": "4.1.0",
"@formatjs/intl-pluralrules": "4.3.3", "@formatjs/intl-pluralrules": "4.3.3",
@@ -3049,7 +3051,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@edx/frontend-build": ">= 8.1.0 || ^12.9.0-alpha.1", "@edx/frontend-build": ">= 8.1.0 || ^12.9.0-alpha.1",
"@edx/paragon": ">= 10.0.0 < 21.0.0", "@edx/paragon": ">= 10.0.0 < 22.0.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^16.9.0 || ^17.0.0", "react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0", "react-dom": "^16.9.0 || ^17.0.0",
@@ -3065,6 +3067,14 @@
"@newrelic/publish-sourcemap": "^5.0.1" "@newrelic/publish-sourcemap": "^5.0.1"
} }
}, },
"node_modules/@edx/openedx-atlas": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@edx/openedx-atlas/-/openedx-atlas-0.5.0.tgz",
"integrity": "sha512-wgcquAVj2BVw1rS7m1LZzUg6wVku37gs/Ks14LPWefpEIQ0HcTFV4nkg8dWfGX5dP3GiEp8s1Up7XwXzlErQhQ==",
"bin": {
"atlas": "atlas"
}
},
"node_modules/@edx/paragon": { "node_modules/@edx/paragon": {
"version": "20.46.2", "version": "20.46.2",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.46.2.tgz", "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.46.2.tgz",

View File

@@ -36,7 +36,8 @@
"@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.2.0", "@edx/frontend-component-footer": "12.2.0",
"@edx/frontend-component-header": "4.6.0", "@edx/frontend-component-header": "4.6.0",
"@edx/frontend-platform": "5.0.0", "@edx/frontend-platform": "5.5.2",
"@edx/openedx-atlas": "^0.5.0",
"@edx/paragon": "^20.44.0", "@edx/paragon": "^20.44.0",
"@edx/tinymce-language-selector": "1.1.0", "@edx/tinymce-language-selector": "1.1.0",
"@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/fontawesome-svg-core": "1.2.36",

View File

@@ -4,6 +4,7 @@
<title>Communications | <%= process.env.SITE_NAME %></title> <title>Communications | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -33,7 +33,11 @@ export default function BulkEmailTool() {
</h1> </h1>
</div> </div>
<div className="row"> <div className="row">
<BulkEmailForm courseId={courseId} cohorts={courseMetadata.cohorts} /> <BulkEmailForm
courseId={courseId}
cohorts={courseMetadata.cohorts}
courseModes={courseMetadata.courseModes}
/>
</div> </div>
<div className="row py-5"> <div className="row py-5">
<BulkEmailTaskManager courseId={courseId} /> <BulkEmailTaskManager courseId={courseId} />

View File

@@ -47,7 +47,12 @@ const FORM_ACTIONS = {
}; };
function BulkEmailForm(props) { function BulkEmailForm(props) {
const { courseId, cohorts, intl } = props; const {
courseId,
cohorts,
courseModes,
intl,
} = props;
const [{ editor }, dispatch] = useContext(BulkEmailContext); const [{ editor }, dispatch] = useContext(BulkEmailContext);
const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT); const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT);
const [emailFormValidation, setEmailFormValidation] = useState({ const [emailFormValidation, setEmailFormValidation] = useState({
@@ -272,10 +277,14 @@ function BulkEmailForm(props) {
handleCheckboxes={onRecipientChange} handleCheckboxes={onRecipientChange}
additionalCohorts={cohorts} additionalCohorts={cohorts}
isValid={emailFormValidation.recipients} isValid={emailFormValidation.recipients}
courseModes={courseModes}
/> />
<Form.Group controlId="emailSubject"> <Form.Group controlId="emailSubject">
<Form.Label className="h3 text-primary-500">{intl.formatMessage(messages.bulkEmailSubjectLabel)}</Form.Label> <Form.Label className="h3 text-primary-500">{intl.formatMessage(messages.bulkEmailSubjectLabel)}</Form.Label>
<Form.Control name="emailSubject" className="w-lg-50" onChange={onFormChange} value={editor.emailSubject} /> <Form.Control name="emailSubject" className="w-lg-50" onChange={onFormChange} value={editor.emailSubject} maxLength={128} />
<Form.Control.Feedback className="px-3" type="default">
{intl.formatMessage(messages.bulkEmailFormSubjectTip)}
</Form.Control.Feedback>
{!emailFormValidation.subject && ( {!emailFormValidation.subject && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid"> <Form.Control.Feedback className="px-3" hasIcon type="invalid">
{intl.formatMessage(messages.bulkEmailFormSubjectError)} {intl.formatMessage(messages.bulkEmailFormSubjectError)}
@@ -384,6 +393,12 @@ BulkEmailForm.propTypes = {
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.string), cohorts: PropTypes.arrayOf(PropTypes.string),
intl: intlShape.isRequired, intl: intlShape.isRequired,
courseModes: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
).isRequired,
}; };
export default injectIntl(BulkEmailForm); export default injectIntl(BulkEmailForm);

View File

@@ -9,6 +9,8 @@ function ScheduleEmailForm(props) {
const isMobile = useMobileResponsive(); const isMobile = useMobileResponsive();
const { isValid, onDateTimeChange, dateTime } = props; const { isValid, onDateTimeChange, dateTime } = props;
const { date, time } = dateTime; const { date, time } = dateTime;
const descriptionDate = new Date();
descriptionDate.setDate(new Date().getDate() + 1);
return ( return (
<Form.Group> <Form.Group>
<div className={classNames('d-flex', isMobile ? 'flex-column' : 'flex-row', 'my-3')}> <div className={classNames('d-flex', isMobile ? 'flex-column' : 'flex-row', 'my-3')}>
@@ -30,7 +32,10 @@ function ScheduleEmailForm(props) {
<small className="text-gray-500 x-small"> <small className="text-gray-500 x-small">
<FormattedMessage <FormattedMessage
id="bulk.email.form.schedule.date.description" id="bulk.email.form.schedule.date.description"
defaultMessage="Enter a start date, e.g. 11/27/2023" defaultMessage="Enter a start date, e.g. {date}"
values={{
date: descriptionDate.toLocaleDateString(),
}}
/> />
</small> </small>
</div> </div>
@@ -52,7 +57,10 @@ function ScheduleEmailForm(props) {
<small className="text-gray-500 x-small"> <small className="text-gray-500 x-small">
<FormattedMessage <FormattedMessage
id="bulk.email.form.schedule.time.description" id="bulk.email.form.schedule.time.description"
defaultMessage="Enter a start time, e.g. 09:00 AM" defaultMessage="Enter a start time, e.g. {time}"
values={{
time: descriptionDate.toLocaleTimeString([], { timeStyle: 'short' }),
}}
/> />
</small> </small>
</div> </div>

View File

@@ -14,7 +14,13 @@ const DEFAULT_GROUPS = {
}; };
export default function BulkEmailRecipient(props) { export default function BulkEmailRecipient(props) {
const { handleCheckboxes, selectedGroups, additionalCohorts } = props; const {
handleCheckboxes,
selectedGroups,
additionalCohorts,
courseModes,
} = props;
const hasCourseModes = courseModes && courseModes.length > 1;
return ( return (
<Form.Group> <Form.Group>
<Form.Label> <Form.Label>
@@ -50,18 +56,24 @@ export default function BulkEmailRecipient(props) {
description="A selectable choice from a list of potential email recipients" description="A selectable choice from a list of potential email recipients"
/> />
</Form.Checkbox> </Form.Checkbox>
<Form.Checkbox {
key="track:verified" // additional modes
value="track:verified" hasCourseModes
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)} && courseModes.map((courseMode) => (
className="col col-lg-4 col-sm-6 col-12" <Form.Checkbox
> key={`track:${courseMode.slug}`}
<FormattedMessage value={`track:${courseMode.slug}`}
id="bulk.email.form.recipients.verified" disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
defaultMessage="Learners in the verified certificate track" className="col col-lg-4 col-sm-6 col-12"
description="A selectable choice from a list of potential email recipients" >
/> <FormattedMessage
</Form.Checkbox> id="bulk.email.form.mode.label"
defaultMessage="Learners in the {courseModeName} Track"
values={{ courseModeName: courseMode.name }}
/>
</Form.Checkbox>
))
}
{ {
// additional cohorts // additional cohorts
additionalCohorts additionalCohorts
@@ -80,18 +92,6 @@ export default function BulkEmailRecipient(props) {
</Form.Checkbox> </Form.Checkbox>
)) ))
} }
<Form.Checkbox
key="track:audit"
value="track:audit"
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
className="col col-lg-4 col-sm-6 col-12"
>
<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 <Form.Checkbox
key="learners" key="learners"
value="learners" value="learners"
@@ -127,4 +127,10 @@ BulkEmailRecipient.propTypes = {
handleCheckboxes: PropTypes.func.isRequired, handleCheckboxes: PropTypes.func.isRequired,
isValid: PropTypes.bool, isValid: PropTypes.bool,
additionalCohorts: PropTypes.arrayOf(PropTypes.string), additionalCohorts: PropTypes.arrayOf(PropTypes.string),
courseModes: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
).isRequired,
}; };

View File

@@ -0,0 +1,22 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
/**
* Generates an array of course mode objects using Rosie Factory.
* @returns {Array<Object>} An array of course mode objects with attributes 'slug' and 'name'.
*/
const courseModeFactory = () => {
const AuditModeFactory = Factory.define('AuditModeFactory')
.attr('slug', 'audit')
.attr('name', 'Audit');
const VerifiedModeFactory = Factory.define('VerifiedModeFactory')
.attr('slug', 'verified')
.attr('name', 'Verified Certificate');
return [
AuditModeFactory.build(),
VerifiedModeFactory.build(),
];
};
export default courseModeFactory;

View File

@@ -41,6 +41,11 @@ const messages = defineMessages({
defaultMessage: 'Subject', defaultMessage: 'Subject',
description: 'Email subject line input label. Meant to have colon or equivilant punctuation.', description: 'Email subject line input label. Meant to have colon or equivilant punctuation.',
}, },
bulkEmailFormSubjectTip: {
id: 'bulk.email.form.subject.tip',
defaultMessage: '(Maximum 128 characters)',
description: 'Default Subject tip',
},
bulkEmailFormSubjectError: { bulkEmailFormSubjectError: {
id: 'bulk.email.form.subject.error', id: 'bulk.email.form.subject.error',
defaultMessage: 'A subject is required', defaultMessage: 'A subject is required',

View File

@@ -12,6 +12,7 @@ import * as bulkEmailFormApi from '../data/api';
import { BulkEmailContext, BulkEmailProvider } from '../../bulk-email-context'; import { BulkEmailContext, BulkEmailProvider } from '../../bulk-email-context';
import { formatDate } from '../../../../utils/formatDateAndTime'; import { formatDate } from '../../../../utils/formatDateAndTime';
import cohortFactory from '../data/__factories__/bulkEmailFormCohort.factory'; import cohortFactory from '../data/__factories__/bulkEmailFormCohort.factory';
import courseModeFactory from '../data/__factories__/bulkEmailFormCourseMode.factory';
jest.mock('../../text-editor/TextEditor'); jest.mock('../../text-editor/TextEditor');
@@ -20,12 +21,17 @@ const dispatchMock = jest.fn();
const tomorrow = new Date(); const tomorrow = new Date();
tomorrow.setDate(new Date().getDate() + 1); tomorrow.setDate(new Date().getDate() + 1);
const courseMode = courseModeFactory();
function renderBulkEmailForm() { function renderBulkEmailForm() {
const { cohorts } = cohortFactory.build(); const { cohorts } = cohortFactory.build();
return ( return (
<BulkEmailProvider> <BulkEmailProvider>
<BulkEmailForm courseId="test" cohorts={cohorts} /> <BulkEmailForm
courseId="test"
cohorts={cohorts}
courseModes={courseMode}
/>
</BulkEmailProvider> </BulkEmailProvider>
); );
} }
@@ -33,7 +39,7 @@ function renderBulkEmailForm() {
function renderBulkEmailFormContext(value) { function renderBulkEmailFormContext(value) {
return ( return (
<BulkEmailContext.Provider value={[value, dispatchMock]}> <BulkEmailContext.Provider value={[value, dispatchMock]}>
<BulkEmailForm courseId="test" /> <BulkEmailForm courseId="test" courseMode={courseMode} />
</BulkEmailContext.Provider> </BulkEmailContext.Provider>
); );
} }
@@ -96,8 +102,8 @@ describe('bulk-email-form', () => {
test('Checking "All Learners" disables each learner group', async () => { test('Checking "All Learners" disables each learner group', async () => {
render(renderBulkEmailForm()); render(renderBulkEmailForm());
fireEvent.click(screen.getByRole('checkbox', { name: 'All Learners' })); fireEvent.click(screen.getByRole('checkbox', { name: 'All Learners' }));
const verifiedLearners = screen.getByRole('checkbox', { name: 'Learners in the verified certificate track' }); const verifiedLearners = screen.getByRole('checkbox', { name: 'Learners in the Verified Certificate Track' });
const auditLearners = screen.getByRole('checkbox', { name: 'Learners in the audit track' }); const auditLearners = screen.getByRole('checkbox', { name: 'Learners in the Audit Track' });
const { cohorts } = cohortFactory.build(); const { cohorts } = cohortFactory.build();
cohorts.forEach(cohort => expect(screen.getByRole('checkbox', { name: `Cohort: ${cohort}` })).toBeDisabled()); cohorts.forEach(cohort => expect(screen.getByRole('checkbox', { name: `Cohort: ${cohort}` })).toBeDisabled());
expect(verifiedLearners).toBeDisabled(); expect(verifiedLearners).toBeDisabled();

View File

@@ -26,6 +26,7 @@ function flattenScheduledEmailsArray(emails) {
emailId: email.courseEmail.id, emailId: email.courseEmail.id,
task: email.task, task: email.task,
taskDue: new Date(email.taskDue).toLocaleString(), taskDue: new Date(email.taskDue).toLocaleString(),
taskDueUTC: email.taskDue,
...email.courseEmail, ...email.courseEmail,
targets: email.courseEmail.targets.join(', '), targets: email.courseEmail.targets.join(', '),
})); }));
@@ -91,10 +92,10 @@ function BulkEmailScheduledEmailsTable({ intl }) {
const handleEditEmail = (row) => { const handleEditEmail = (row) => {
const { const {
original: { original: {
htmlMessage: emailBody, subject: emailSubject, taskDue, targets, schedulingId, emailId, htmlMessage: emailBody, subject: emailSubject, taskDueUTC, targets, schedulingId, emailId,
}, },
} = row; } = row;
const dateTime = new Date(taskDue); const dateTime = new Date(taskDueUTC);
const emailRecipients = targets.replaceAll('-', ':').split(', '); const emailRecipients = targets.replaceAll('-', ':').split(', ');
const scheduleDate = formatDate(dateTime); const scheduleDate = formatDate(dateTime);
const scheduleTime = formatTime(dateTime); const scheduleTime = formatTime(dateTime);

View File

@@ -39,7 +39,7 @@ export default function PageContainer(props) {
} }
const { const {
org, number, title, tabs, originalUserIsStaff, org, number, title, tabs, originalUserIsStaff, courseModes,
} = metadataResponse; } = metadataResponse;
const { cohorts } = cohortsResponse; const { cohorts } = cohortsResponse;
@@ -48,6 +48,7 @@ export default function PageContainer(props) {
number, number,
title, title,
originalUserIsStaff, originalUserIsStaff,
courseModes,
tabs: [...tabs], tabs: [...tabs],
cohorts: cohorts.map(({ name }) => name), cohorts: cohorts.map(({ name }) => name),
}); });
@@ -66,9 +67,11 @@ export default function PageContainer(props) {
courseNumber={courseMetadata.number} courseNumber={courseMetadata.number}
courseTitle={courseMetadata.title} courseTitle={courseMetadata.title}
/> />
<main> <div className="pb-3 container">
{children} <main>
</main> {children}
</main>
</div>
<Footer /> <Footer />
</> </>
</CourseMetadataContext.Provider> </CourseMetadataContext.Provider>

View File

@@ -21,20 +21,18 @@ subscribe(APP_READY, () => {
<Helmet> <Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" /> <link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet> </Helmet>
<div className="pb-3 container"> <Routes>
<Routes> <Route
<Route path="/courses/:courseId/bulk_email"
path="/courses/:courseId/bulk_email" element={(
element={( <AuthenticatedPageRoute>
<AuthenticatedPageRoute> <PageContainer>
<PageContainer> <BulkEmailTool />
<BulkEmailTool /> </PageContainer>
</PageContainer> </AuthenticatedPageRoute>
</AuthenticatedPageRoute> )}
)} />
/> </Routes>
</Routes>
</div>
</AppProvider>, </AppProvider>,
document.getElementById('root'), document.getElementById('root'),
); );