Compare commits

..

14 Commits

Author SHA1 Message Date
Bilal Qamar
0f9e520481 refactor: updated package-lock 2022-12-29 12:08:41 +05:00
Bilal Qamar
9eeda23ceb Merge branch 'master' of https://github.com/edx/frontend-app-communications into bilalqamar95/frontend-build-upgrade 2022-12-29 12:01:43 +05:00
Bilal Qamar
fe9d201bd5 refactor: pinned frontend-build version 2022-10-31 13:11:09 +05:00
Bilal Qamar
3586fab6b1 refactor: updated frontend-build & resolved eslint issues 2022-10-25 15:43:19 +05:00
Bilal Qamar
8a6cd7937a Merge branch 'master' of https://github.com/edx/frontend-app-communications into bilalqamar95/frontend-build-upgrade 2022-10-25 15:20:58 +05:00
Bilal Qamar
c6563d8ef6 refactor: removed ignored rules from eslintrc & correct eslint issues 2022-09-12 16:38:42 +05:00
Bilal Qamar
f14e80099e refactor: pinned frontend-build version & ignored jsx-no-constructed-context-values for tests 2022-09-08 19:32:57 +05:00
Bilal Qamar
4977918e65 refactor: resolved merge conflicts with master branch 2022-09-01 12:33:47 +05:00
Bilal Qamar
4e2577191d refactor: resolved eslint issues after merge with master 2022-08-24 17:06:13 +05:00
Bilal Qamar
b849006b34 refactor: resolved merge conflicts with master 2022-08-24 17:01:24 +05:00
Bilal Qamar
c3088d986e refactor: resolved merge conflicts with master 2022-08-10 16:30:51 +05:00
Bilal Qamar
4b7068b49e refactor: updated eslintrc & resolved unstable nested component error 2022-08-05 16:34:29 +05:00
Bilal Qamar
bb28674c1b refactor: resolved eslint issues 2022-08-05 16:34:19 +05:00
Bilal Qamar
91e4e4b9df refactor: updated frontend-build to v12 2022-08-05 16:33:02 +05:00
47 changed files with 30906 additions and 5458 deletions

2
.env
View File

@@ -19,5 +19,3 @@ SEGMENT_KEY=''
SITE_NAME=''
USER_INFO_COOKIE_NAME=''
SCHEDULE_EMAIL_SECTION=''
APP_ID=''
MFE_CONFIG_API_URL=''

View File

@@ -20,5 +20,3 @@ SEGMENT_KEY=''
SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
SCHEDULE_EMAIL_SECTION='true'
APP_ID=''
MFE_CONFIG_API_URL=''

View File

@@ -18,5 +18,3 @@ SEGMENT_KEY=''
SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
SCHEDULE_EMAIL_SECTION='true'
APP_ID=''
MFE_CONFIG_API_URL=''

View File

@@ -1,9 +1,4 @@
/* eslint-disable import/no-extraneous-dependencies */
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint', {
rules: {
'react/function-component-definition': 'off',
},
});
module.exports = createConfig('eslint');

View File

@@ -1,19 +0,0 @@
# Run the workflow that adds new tickets that are either:
# - labelled "DEPR"
# - title starts with "[DEPR]"
# - body starts with "Proposal Date" (this is the first template field)
# to the org-wide DEPR project board
name: Add newly created DEPR issues to the DEPR project board
on:
issues:
types: [opened]
jobs:
routeissue:
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,20 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -9,18 +9,18 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v3
uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VER }}
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes
@@ -34,4 +34,4 @@ jobs:
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v2

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

View File

@@ -1,12 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

View File

@@ -1,12 +0,0 @@
name: Update Browserslist DB
on:
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
update-browserslist:
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
secrets:
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}

2
.nvmrc
View File

@@ -1,2 +1,2 @@
18
16

View File

@@ -1,7 +1,11 @@
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_resource = frontend-app-communications
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
@@ -29,17 +33,20 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-app-communications/src/i18n/messages:frontend-app-communications
# 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/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-app-communications
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,48 +1,45 @@
|Codecov| |license|
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-communications
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
frontend-app-communications
#############################
==============================
|license-badge| |status-badge| |ci-badge| |codecov-badge|
Please tag **edx-aperture** on any PRs or issues. Thanks!
Introduction
------------
Purpose
*******
A tool used by course teams to communicate with thier learners. The interface for anything related to instructor to learner communications. Instructor bulk email, for example.
A tool used by course teams to communicate with their learners. The interface for anything related to instructor to learner communications. Instructor bulk email, for example.
Getting started
------------
For now, this repo is not intergrated with devstack. You'll be running the app locally and not through docker. This does make setup a little easier.
Cloning and Startup
===================
1. Clone the repo into your usual workspace
1. Clone your new repo:
.. code-block::
``git clone https://github.com/edx/frontend-app-communications.git``
mkdir -p ~/workspace/
cd ~/workspace/
git clone https://github.com/edx/frontend-app-communications.git
2. Use node v18.x.
2. Install frontend dependencies
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
.. code-block::
3. Install npm dependencies:
npm i
``cd frontend-app-communications && npm install``
3. Start the devserver. The app will be running at ``localhost:1984``, or whatever port you change it too.
4. Update the application port to use for local development:
Default port is 1984. If this does not work for you, update the line
`PORT=1984` to your port in all .env.* files
5. Start the devserver. The app will be running at ``localhost:1984``, or whatever port you change it too.
.. code-block::
npm start
.. code-block::
npm start
Environment Variables/Setup Notes
---------------------------------
@@ -68,96 +65,3 @@ Tests use `jest` and `react-test-library`. To run all the tests for this repo:
.. code-block::
npm test
**Production Build**
The production build is created with ``npm run build``.
Internationalization
====================
Please see refer to the `frontend-platform i18n howto`_ for documentation on
internationalization.
.. _frontend-platform i18n howto: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst
Getting Help
************
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-communications/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
License
*******
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
************
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
The Open edX Code of Conduct
****************************
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
******
The assigned maintainers for this component and other project details may be
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/frontend-app-communications
Reporting Security Issues
*************************
Please do not report security issues in public, and email security@openedx.org instead.
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-communications.svg
:target: https://github.com/openedx/frontend-app-communications/blob/master/LICENSE
:alt: License
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
.. |ci-badge| image:: https://github.com/openedx/frontend-app-communications/actions/workflows/ci.yml/badge.svg
:target: https://github.com/openedx/frontend-app-communications/actions/workflows/ci.yml
:alt: Continuous Integration
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-communications/coverage.svg?branch=master
:target: https://codecov.io/github/openedx/frontend-app-communications?branch=master
:alt: Codecov

View File

@@ -1,18 +0,0 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: "frontend-app-communications"
description: "A tool used by course teams to communicate with their learners."
links:
- url: "https://github.com/openedx/frontend-app-communications/blob/master/README.rst"
title: "README"
icon: "Article"
annotations:
openedx.org/arch-interest-groups: ""
spec:
owner: group:frontend-all
type: "service"
lifecycle: "production"

35255
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,11 +34,10 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.2.0",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-platform": "5.5.2",
"@edx/openedx-atlas": "^0.5.0",
"@edx/paragon": "^20.44.0",
"@edx/frontend-component-footer": "11.2.1",
"@edx/frontend-component-header": "3.5.0",
"@edx/frontend-platform": "2.6.2",
"@edx/paragon": "^20.20.0",
"@edx/tinymce-language-selector": "1.1.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
@@ -52,27 +51,27 @@
"jquery": "3.6.1",
"popper.js": "1.16.1",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"redux": "4.2.0",
"regenerator-runtime": "0.13.11",
"tinymce": "5.10.7"
},
"devDependencies": {
"@edx/browserslist-config": "^1.2.0",
"@edx/frontend-build": "^12.7.0",
"@edx/reactifex": "^2.1.1",
"@edx/browserslist-config": "^1.1.0",
"@edx/frontend-build": "12.3.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"axios-mock-adapter": "1.21.2",
"codecov": "3.8.3",
"glob": "7.2.3",
"husky": "7.0.4",
"jest": "27.5.1",
"prettier": "2.8.1",
"reactifex": "1.1.1",
"rosie": "2.1.0"
}
}

View File

@@ -12,7 +12,7 @@ import { CourseMetadataContext } from '../page-container/PageContainer';
import { BulkEmailProvider } from './bulk-email-context';
import BackToInstructor from '../navigation-tabs/BackToInstructor';
export default function BulkEmailTool() {
const BulkEmailTool = () => {
const { courseId } = useParams();
return (
@@ -22,7 +22,7 @@ export default function BulkEmailTool() {
<NavigationTabs courseId={courseId} tabData={courseMetadata.tabs} />
<BulkEmailProvider>
<Container size="md">
<BackToInstructor courseId={courseId} />
<BackToInstructor />
<div className="row pb-4.5">
<h1 className="text-primary-500" id="main-content">
<FormattedMessage
@@ -33,11 +33,7 @@ export default function BulkEmailTool() {
</h1>
</div>
<div className="row">
<BulkEmailForm
courseId={courseId}
cohorts={courseMetadata.cohorts}
courseModes={courseMetadata.courseModes}
/>
<BulkEmailForm courseId={courseId} cohorts={courseMetadata.cohorts} />
</div>
<div className="row py-5">
<BulkEmailTaskManager courseId={courseId} />
@@ -50,4 +46,6 @@ export default function BulkEmailTool() {
))}
</CourseMetadataContext.Consumer>
);
}
};
export default BulkEmailTool;

View File

@@ -1,6 +1,4 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import useAsyncReducer, { combineReducers } from '../../../utils/useAsyncReducer';
import editor, { editorInitialState } from '../bulk-email-form/data/reducer';
@@ -10,7 +8,7 @@ import scheduledEmailsTable, {
export const BulkEmailContext = React.createContext();
export default function BulkEmailProvider({ children }) {
const BulkEmailProvider = ({ children }) => {
const initialState = {
editor: editorInitialState,
scheduledEmailsTable: scheduledEmailsTableInitialState,
@@ -19,9 +17,14 @@ export default function BulkEmailProvider({ children }) {
combineReducers({ editor, scheduledEmailsTable }),
initialState,
);
return <BulkEmailContext.Provider value={[state, dispatch]}>{children}</BulkEmailContext.Provider>;
}
const contextValue = useMemo(() => ([state, dispatch]), [dispatch, state]);
return <BulkEmailContext.Provider value={contextValue}>{children}</BulkEmailContext.Provider>;
};
BulkEmailProvider.propTypes = {
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};
export default BulkEmailProvider;

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/no-unstable-nested-components */
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
@@ -46,13 +45,53 @@ const FORM_ACTIONS = {
PATCH: 'PATCH',
};
function BulkEmailForm(props) {
const {
courseId,
cohorts,
courseModes,
intl,
} = props;
const AlertMessage = (intl, editor, isScheduled) => (
<>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertRecipients, { subject: editor.emailSubject })}</p>
<ul className="list-unstyled">
{editor.emailRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
{!isScheduled && (
<p>
<strong>{intl.formatMessage(messages.bulkEmailInstructionsCaution)}</strong>
{intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)}
</p>
)}
</>
);
const EditMessage = (intl, editor, isScheduled) => (
<>
<p>
{intl.formatMessage(messages.bulkEmailTaskAlertEditingDate, {
dateTime: new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toLocaleString(),
})}
</p>
<p>
{intl.formatMessage(messages.bulkEmailTaskAlertEditingSubject, {
subject: editor.emailSubject,
})}
</p>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertEditingTo)}</p>
<ul className="list-unstyled">
{editor.emailRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertEditingWarning)}</p>
{!isScheduled && (
<p>
<strong>{intl.formatMessage(messages.bulkEmailInstructionsCaution)}</strong>
{intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)}
</p>
)}
</>
);
const BulkEmailForm = (props) => {
const { courseId, cohorts, intl } = props;
const [{ editor }, dispatch] = useContext(BulkEmailContext);
const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT);
const [emailFormValidation, setEmailFormValidation] = useState({
@@ -211,59 +250,15 @@ function BulkEmailForm(props) {
} else {
setEmailFormStatus(FORM_SUBMIT_STATES.DEFAULT);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScheduled, editor.editMode, editor.isLoading, editor.errorRetrievingData, editor.formComplete]);
const AlertMessage = () => (
<>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertRecipients, { subject: editor.emailSubject })}</p>
<ul className="list-unstyled">
{editor.emailRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
{!isScheduled && (
<p>
<strong>{intl.formatMessage(messages.bulkEmailInstructionsCaution)}</strong>
{intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)}
</p>
)}
</>
);
const EditMessage = () => (
<>
<p>
{intl.formatMessage(messages.bulkEmailTaskAlertEditingDate, {
dateTime: new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toLocaleString(),
})}
</p>
<p>
{intl.formatMessage(messages.bulkEmailTaskAlertEditingSubject, {
subject: editor.emailSubject,
})}
</p>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertEditingTo)}</p>
<ul className="list-unstyled">
{editor.emailRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertEditingWarning)}</p>
{!isScheduled && (
<p>
<strong>{intl.formatMessage(messages.bulkEmailInstructionsCaution)}</strong>
{intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)}
</p>
)}
</>
);
}, [isScheduled, editor.editMode, editor.isLoading, editor.errorRetrievingData,
editor.formComplete, delayedEmailFormReset]);
return (
<div className={classNames('w-100 m-auto', !isMobile && 'p-4 border border-primary-200')}>
<TaskAlertModal
isOpen={isTaskAlertOpen}
alertMessage={editor.editMode ? EditMessage() : AlertMessage()}
alertMessage={editor.editMode
? EditMessage(intl, editor, isScheduled) : AlertMessage(intl, editor, isScheduled)}
close={(event) => {
closeTaskAlert();
if (event.target.name === 'continue') {
@@ -277,14 +272,10 @@ function BulkEmailForm(props) {
handleCheckboxes={onRecipientChange}
additionalCohorts={cohorts}
isValid={emailFormValidation.recipients}
courseModes={courseModes}
/>
<Form.Group controlId="emailSubject">
<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} maxLength={128} />
<Form.Control.Feedback className="px-3" type="default">
{intl.formatMessage(messages.bulkEmailFormSubjectTip)}
</Form.Control.Feedback>
<Form.Control name="emailSubject" className="w-lg-50" onChange={onFormChange} value={editor.emailSubject} />
{!emailFormValidation.subject && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
{intl.formatMessage(messages.bulkEmailFormSubjectError)}
@@ -383,7 +374,7 @@ function BulkEmailForm(props) {
</Form>
</div>
);
}
};
BulkEmailForm.defaultProps = {
cohorts: [],
@@ -393,12 +384,6 @@ BulkEmailForm.propTypes = {
courseId: PropTypes.string.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.string),
intl: intlShape.isRequired,
courseModes: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
).isRequired,
};
export default injectIntl(BulkEmailForm);

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
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,11 +41,6 @@ const messages = defineMessages({
defaultMessage: 'Subject',
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: {
id: 'bulk.email.form.subject.error',
defaultMessage: 'A subject is required',

View File

@@ -12,7 +12,6 @@ import * as bulkEmailFormApi from '../data/api';
import { BulkEmailContext, BulkEmailProvider } from '../../bulk-email-context';
import { formatDate } from '../../../../utils/formatDateAndTime';
import cohortFactory from '../data/__factories__/bulkEmailFormCohort.factory';
import courseModeFactory from '../data/__factories__/bulkEmailFormCourseMode.factory';
jest.mock('../../text-editor/TextEditor');
@@ -21,25 +20,20 @@ const dispatchMock = jest.fn();
const tomorrow = new Date();
tomorrow.setDate(new Date().getDate() + 1);
const courseMode = courseModeFactory();
function renderBulkEmailForm() {
const { cohorts } = cohortFactory.build();
return (
<BulkEmailProvider>
<BulkEmailForm
courseId="test"
cohorts={cohorts}
courseModes={courseMode}
/>
<BulkEmailForm courseId="test" cohorts={cohorts} />
</BulkEmailProvider>
);
}
function renderBulkEmailFormContext(value) {
return (
<BulkEmailContext.Provider value={[value, dispatchMock]}>
<BulkEmailForm courseId="test" courseMode={courseMode} />
<BulkEmailContext.Provider value={value}>
<BulkEmailForm courseId="test" />
</BulkEmailContext.Provider>
);
}
@@ -102,8 +96,8 @@ describe('bulk-email-form', () => {
test('Checking "All Learners" disables each learner group', async () => {
render(renderBulkEmailForm());
fireEvent.click(screen.getByRole('checkbox', { name: 'All Learners' }));
const verifiedLearners = screen.getByRole('checkbox', { name: 'Learners in the Verified Certificate Track' });
const auditLearners = screen.getByRole('checkbox', { name: 'Learners in the Audit 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 { cohorts } = cohortFactory.build();
cohorts.forEach(cohort => expect(screen.getByRole('checkbox', { name: `Cohort: ${cohort}` })).toBeDisabled());
expect(verifiedLearners).toBeDisabled();
@@ -150,7 +144,7 @@ describe('bulk-email-form', () => {
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onPatch().reply(200);
render(
renderBulkEmailFormContext({
renderBulkEmailFormContext([{
editor: {
editMode: true,
emailBody: 'test',
@@ -163,7 +157,7 @@ describe('bulk-email-form', () => {
isLoading: false,
errorRetrievingData: false,
},
}),
}, dispatchMock]),
);
const submitButton = screen.getByText('Reschedule Email');
fireEvent.click(submitButton);

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/no-unstable-nested-components */
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom';
@@ -14,7 +12,7 @@ import { getSentEmailHistory } from './data/api';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
import ViewEmailModal from './ViewEmailModal';
function BulkEmailContentHistory({ intl }) {
const BulkEmailContentHistory = ({ intl }) => {
const { courseId } = useParams();
const [emailHistoryData, setEmailHistoryData] = useState();
const [errorRetrievingData, setErrorRetrievingData] = useState(false);
@@ -26,7 +24,7 @@ function BulkEmailContentHistory({ intl }) {
* Async function that makes a REST API call to retrieve historical email message data sent by the bulk course email
* tool from edx-platform.
*/
async function fetchSentEmailHistoryData() {
const fetchSentEmailHistoryData = async () => {
setErrorRetrievingData(false);
setShowHistoricalEmailContentTable(false);
@@ -43,7 +41,7 @@ function BulkEmailContentHistory({ intl }) {
}
setShowHistoricalEmailContentTable(true);
}
};
/**
* This utility function transforms the data stored in `emailHistoryData` to make it easier to display in the Paragon
@@ -96,6 +94,13 @@ function BulkEmailContentHistory({ intl }) {
},
];
const getViewMessageCell = (tableData, row) => (
<Button variant="link" className="px-1" onClick={() => onViewMessageClick(tableData[row.index])}>
{intl.formatMessage(messages.buttonViewMessage)}
<span className="sr-only">&nbsp;{row.index}</span>
</Button>
);
/**
* Paragon's DataTable supports the ability to add extra columns that might not directly coincide with the data being
* represented in the table. We are using an additional column to embed a button that will open a Modal to display the
@@ -108,12 +113,7 @@ function BulkEmailContentHistory({ intl }) {
{
id: 'view_message',
Header: '',
Cell: ({ row }) => (
<Button variant="link" className="px-1" onClick={() => onViewMessageClick(tableData[row.index])}>
{intl.formatMessage(messages.buttonViewMessage)}
<span className="sr-only">&nbsp;{row.index}</span>
</Button>
),
Cell: ({ row }) => getViewMessageCell(tableData, row),
},
];
};
@@ -133,7 +133,6 @@ function BulkEmailContentHistory({ intl }) {
styling="card"
title={intl.formatMessage(messages.emailHistoryTableSectionButton)}
className="mb-3"
// eslint-disable-next-line react/jsx-no-bind
onOpen={fetchSentEmailHistoryData}
>
{showHistoricalEmailContentTable ? (
@@ -153,7 +152,7 @@ function BulkEmailContentHistory({ intl }) {
</div>
</div>
);
}
};
BulkEmailContentHistory.propTypes = {
intl: intlShape.isRequired,

View File

@@ -2,7 +2,7 @@ import { Alert, DataTable } from '@edx/paragon';
import PropTypes from 'prop-types';
import React from 'react';
export default function BulkEmailTaskManagerTable(props) {
const BulkEmailTaskManagerTable = (props) => {
const {
errorRetrievingData,
tableData,
@@ -67,7 +67,7 @@ export default function BulkEmailTaskManagerTable(props) {
{canRenderTable()}
</div>
);
}
};
BulkEmailTaskManagerTable.propTypes = {
errorRetrievingData: PropTypes.bool.isRequired,
@@ -84,3 +84,5 @@ BulkEmailTaskManagerTable.defaultProps = {
tableDescription: '',
additionalColumns: [],
};
export default BulkEmailTaskManagerTable;

View File

@@ -7,7 +7,7 @@ import messages from './messages';
import useInterval from '../../../utils/useInterval';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
function BulkEmailPendingTasks({ intl }) {
const BulkEmailPendingTasks = ({ intl }) => {
const { courseId } = useParams();
const [instructorTaskData, setInstructorTaskData] = useState();
@@ -87,7 +87,7 @@ function BulkEmailPendingTasks({ intl }) {
/>
</div>
);
}
};
BulkEmailPendingTasks.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,40 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { Hyperlink, Alert } from '@edx/paragon';
import { WarningFilled } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
export default function BulkEmailPendingTasksAlert(props) {
const { courseId } = props;
return (
<Alert variant="warning" icon={WarningFilled}>
const BulkEmailPendingTasksAlert = () => (
<Alert variant="warning" icon={WarningFilled}>
<FormattedMessage
id="bulk.email.pending.tasks.description.one"
defaultMessage="To view all pending tasks, including email, visit&nbsp;"
/>
<Hyperlink
destination={`${getConfig().LMS_BASE_URL}/courses/${window.location.pathname.split('/')[2]}/instructor#view-course-info`}
target="_blank"
isInline
showLaunchIcon={false}
>
<FormattedMessage
id="bulk.email.pending.tasks.description.one"
defaultMessage="To view all pending tasks, including email, visit&nbsp;"
id="bulk.email.pending.tasks.link"
defaultMessage="Course Info"
/>
<Hyperlink
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor#view-course-info`}
target="_blank"
isInline
showLaunchIcon={false}
>
<FormattedMessage
id="bulk.email.pending.tasks.link"
defaultMessage="Course Info"
/>
</Hyperlink>
<FormattedMessage
id="bulk.email.pending.tasks.description.two"
defaultMessage="&nbsp;in the Instructor Dashboard."
/>
</Alert>
</Hyperlink>
<FormattedMessage
id="bulk.email.pending.tasks.description.two"
defaultMessage="&nbsp;in the Instructor Dashboard."
/>
</Alert>
);
);
}
BulkEmailPendingTasksAlert.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default BulkEmailPendingTasksAlert;

View File

@@ -11,7 +11,7 @@ import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
import './bulkEmailTaskHistory.scss';
function BulkEmailTaskHistory({ intl }) {
const BulkEmailTaskHistory = ({ intl }) => {
const { courseId } = useParams();
const [emailTaskHistoryData, setEmailTaskHistoryData] = useState([]);
@@ -22,7 +22,7 @@ function BulkEmailTaskHistory({ intl }) {
* Async function that makes a REST API call to retrieve historical bulk email (Instructor) task data for display
* within this component.
*/
async function fetchEmailTaskHistoryData() {
const fetchEmailTaskHistoryData = async () => {
setErrorRetrievingData(false);
setShowHistoricalTaskContentTable(false);
@@ -39,7 +39,7 @@ function BulkEmailTaskHistory({ intl }) {
}
setShowHistoricalTaskContentTable(true);
}
};
const tableColumns = [
{
@@ -89,7 +89,6 @@ function BulkEmailTaskHistory({ intl }) {
<Collapsible
styling="card"
title={intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}
// eslint-disable-next-line react/jsx-no-bind
onOpen={fetchEmailTaskHistoryData}
>
{showHistoricalTaskContentTable ? (
@@ -107,7 +106,7 @@ function BulkEmailTaskHistory({ intl }) {
</div>
</div>
);
}
};
BulkEmailTaskHistory.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,5 +1,4 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
@@ -9,33 +8,30 @@ import messages from './messages';
import BulkEmailScheduledEmailsTable from './bulk-email-scheduled-emails-table';
import BulkEmailPendingTasksAlert from './BulkEmailPendingTasksAlert';
function BulkEmailTaskManager({ intl, courseId }) {
return (
<div className="w-100">
{getConfig().SCHEDULE_EMAIL_SECTION && (
<div>
<h2 className="h3 text-primary-500">{intl.formatMessage(messages.scheduledEmailsTableHeader)}</h2>
<BulkEmailScheduledEmailsTable />
</div>
)}
<div>
<h2 className="h3 text-primary-500">{intl.formatMessage(messages.emailTaskHistoryHeader)}</h2>
<BulkEmailContentHistory />
</div>
<div>
<BulkEmailTaskHistory />
</div>
<div className="border-top border-primary-500 pt-4.5">
<h2 className="h3 mb-4 text-primary-500">{intl.formatMessage(messages.pendingTasksHeader)}</h2>
<BulkEmailPendingTasksAlert courseId={courseId} />
</div>
const BulkEmailTaskManager = ({ intl }) => (
<div className="w-100">
{getConfig().SCHEDULE_EMAIL_SECTION && (
<div>
<h2 className="h3 text-primary-500">{intl.formatMessage(messages.scheduledEmailsTableHeader)}</h2>
<BulkEmailScheduledEmailsTable />
</div>
);
}
)}
<div>
<h2 className="h3 text-primary-500">{intl.formatMessage(messages.emailTaskHistoryHeader)}</h2>
<BulkEmailContentHistory />
</div>
<div>
<BulkEmailTaskHistory />
</div>
<div className="border-top border-primary-500 pt-4.5">
<h2 className="h3 mb-4 text-primary-500">{intl.formatMessage(messages.pendingTasksHeader)}</h2>
<BulkEmailPendingTasksAlert />
</div>
</div>
);
BulkEmailTaskManager.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
export default injectIntl(BulkEmailTaskManager);

View File

@@ -1,24 +1,21 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { ActionRow, Button, ModalDialog } from '@edx/paragon';
import { Button, Modal } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { BulkEmailContext } from '../bulk-email-context';
import { copyToEditor } from '../bulk-email-form/data/actions';
function ViewEmailModal({
const ViewEmailModal = ({
intl, messageContent, isOpen, setModalOpen,
}) {
}) => {
const [, dispatch] = useContext(BulkEmailContext);
return (
<div>
<ModalDialog
isOpen={isOpen}
onClose={() => setModalOpen(false)}
hasCloseButton
>
<ModalDialog.Body>
<Modal
open={isOpen}
title=""
body={(
<div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageSubject)}</p>
@@ -43,33 +40,27 @@ function ViewEmailModal({
<div dangerouslySetInnerHTML={{ __html: messageContent.email.html_message }} />
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="link">
<FormattedMessage id="bulk.email.tool.close.modalDialog.button" defaultMessage="Close" />
</ModalDialog.CloseButton>
<Button
onClick={() => {
dispatch(
copyToEditor({
emailBody: messageContent.email.html_message,
emailSubject: messageContent.subject,
}),
);
setModalOpen(false);
}}
variant="primary"
>
<FormattedMessage id="bulk.email.tool.copy.message.button" defaultMessage="Copy to editor" />
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
)}
onClose={() => setModalOpen(false)}
buttons={[
<Button
onClick={() => {
dispatch(
copyToEditor({
emailBody: messageContent.email.html_message,
emailSubject: messageContent.subject,
}),
);
setModalOpen(false);
}}
>
<FormattedMessage id="bulk.email.tool.copy.message.button" defaultMessage="Copy to editor" />
</Button>,
]}
/>
</div>
);
}
};
ViewEmailModal.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,6 +1,4 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/no-unstable-nested-components */
import React, {
useCallback, useContext, useState, useEffect,
} from 'react';
@@ -26,13 +24,12 @@ function flattenScheduledEmailsArray(emails) {
emailId: email.courseEmail.id,
task: email.task,
taskDue: new Date(email.taskDue).toLocaleString(),
taskDueUTC: email.taskDue,
...email.courseEmail,
targets: email.courseEmail.targets.join(', '),
}));
}
function BulkEmailScheduledEmailsTable({ intl }) {
const BulkEmailScheduledEmailsTable = ({ intl }) => {
const { courseId } = useParams();
const [{ scheduledEmailsTable }, dispatch] = useContext(BulkEmailContext);
const [tableData, setTableData] = useState([]);
@@ -49,7 +46,7 @@ function BulkEmailScheduledEmailsTable({ intl }) {
const fetchTableData = useCallback((args) => {
dispatch(getScheduledBulkEmailThunk(courseId, args.pageIndex + 1));
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleViewEmail = (row) => {
@@ -92,10 +89,10 @@ function BulkEmailScheduledEmailsTable({ intl }) {
const handleEditEmail = (row) => {
const {
original: {
htmlMessage: emailBody, subject: emailSubject, taskDueUTC, targets, schedulingId, emailId,
htmlMessage: emailBody, subject: emailSubject, taskDue, targets, schedulingId, emailId,
},
} = row;
const dateTime = new Date(taskDueUTC);
const dateTime = new Date(taskDue);
const emailRecipients = targets.replaceAll('-', ':').split(', ');
const scheduleDate = formatDate(dateTime);
const scheduleTime = formatTime(dateTime);
@@ -112,6 +109,25 @@ function BulkEmailScheduledEmailsTable({ intl }) {
}),
);
};
const getCell = (row, state, page, previousPage) => (
<>
<IconButton src={Visibility} iconAs={Icon} alt="View" onClick={() => handleViewEmail(row)} />
<IconButton
src={Delete}
iconAs={Icon}
alt="Delete"
onClick={() => {
setCurrentTask({
row, pageIndex: state.pageIndex, page, previousPage,
});
openConfirmModal();
}}
/>
<IconButton src={Edit} iconAs={Icon} alt="Edit" onClick={() => handleEditEmail(row)} />
</>
);
return (
<>
<TaskAlertModal
@@ -171,30 +187,14 @@ function BulkEmailScheduledEmailsTable({ intl }) {
Header: 'Action',
Cell: ({
row, state, page, previousPage,
}) => (
<>
<IconButton src={Visibility} iconAs={Icon} alt="View" onClick={() => handleViewEmail(row)} />
<IconButton
src={Delete}
iconAs={Icon}
alt="Delete"
onClick={() => {
setCurrentTask({
row, pageIndex: state.pageIndex, page, previousPage,
});
openConfirmModal();
}}
/>
<IconButton src={Edit} iconAs={Icon} alt="Edit" onClick={() => handleEditEmail(row)} />
</>
),
}) => getCell(row, state, page, previousPage),
},
]}
/>
</div>
</>
);
}
};
BulkEmailScheduledEmailsTable.propTypes = {
intl: intlShape.isRequired,

View File

@@ -34,10 +34,6 @@ const messages = defineMessages({
id: 'bulk.email.content.history.table.modal.messageBody',
defaultMessage: 'Message:',
},
modalCloseButton: {
id: 'bulk.email.tool.close.modalDialog.button',
defaultMessage: 'Close',
},
emailHistoryTableViewMessageInstructions: {
id: 'bulk.email.content.history.table.viewMessageInstructions',
defaultMessage: 'To read a sent email message, click the `View Message` button within the table.',

View File

@@ -1,33 +0,0 @@
import React from 'react';
import BulkEmailPendingTasksAlert from '../BulkEmailPendingTasksAlert';
import {
initializeMockApp, render, screen,
} from '../../../../setupTest';
describe('Testing BulkEmailPendingTasksAlert Component', () => {
beforeAll(async () => {
await initializeMockApp();
});
test('Render without Public path', async () => {
render(<BulkEmailPendingTasksAlert courseId="test-course-id" />);
const linkEl = await screen.findByText('Course Info');
expect(linkEl.href).toEqual('http://localhost:18000/courses/test-course-id/instructor#view-course-info');
});
test('Render with Public path', async () => {
Object.defineProperty(window, 'location', {
get() {
return { pathname: '/communications/courses/test-course-id/bulk-email' };
},
});
render(<BulkEmailPendingTasksAlert courseId="test-course-id" />);
const linkEl = await screen.findByText('Course Info');
expect(linkEl.href).toEqual('http://localhost:18000/courses/test-course-id/instructor#view-course-info');
expect(window.location.pathname).toEqual('/communications/courses/test-course-id/bulk-email');
});
});

View File

@@ -3,7 +3,7 @@ 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 TaskAlertModal = (props) => {
const {
isOpen, close, alertMessage, intl,
} = props;
@@ -51,7 +51,7 @@ function TaskAlertModal(props) {
{alertMessage}
</AlertModal>
);
}
};
TaskAlertModal.propTypes = {
isOpen: PropTypes.bool.isRequired,

View File

@@ -20,7 +20,7 @@ import '@edx/tinymce-language-selector';
import contentUiCss from 'tinymce/skins/ui/oxide/content.css';
import contentCss from 'tinymce/skins/content/default/content.css';
export default function TextEditor(props) {
const TextEditor = (props) => {
const {
onChange, onKeyUp, onInit, disabled, value,
} = props;
@@ -43,8 +43,6 @@ export default function TextEditor(props) {
block_unsupported_drop: false,
image_advtab: true,
name: 'emailBody',
relative_urls: false,
remove_script_host: false,
}}
onEditorChange={onChange}
value={value}
@@ -53,7 +51,7 @@ export default function TextEditor(props) {
disabled={disabled}
/>
);
}
};
TextEditor.defaultProps = {
onChange: () => {},
@@ -70,3 +68,5 @@ TextEditor.propTypes = {
disabled: PropTypes.bool,
value: PropTypes.string,
};
export default TextEditor;

View File

@@ -6,18 +6,16 @@ import PropTypes from 'prop-types';
* with jest, so we need to mock it out. This is not ideal, but since the TextEditor
* component is really just a wrapper, we're not too concerned about unit testing.
*/
function MockTinyMCE({ onChange }) {
return <textarea data-testid="textEditor" onChange={onChange} />;
}
const MockTinyMCE = ({ onChange }) => <textarea data-testid="textEditor" onChange={onChange} />;
MockTinyMCE.propTypes = {
onChange: PropTypes.func.isRequired,
};
export default function TextEditor({ onChange }) {
return <MockTinyMCE onChange={onChange} />;
}
const TextEditor = ({ onChange }) => <MockTinyMCE onChange={onChange} />;
TextEditor.propTypes = {
onChange: PropTypes.func.isRequired,
};
export default TextEditor;

View File

@@ -1,33 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
export default function BackToInstructor(props) {
const { courseId } = props;
const BackToInstructor = () => (
<Button
variant="tertiary"
className="mb-4.5 ml-n4.5 text-primary-500"
href={`${getConfig().LMS_BASE_URL}/courses/${window.location.pathname.split('/')[2]}/instructor#view-course-info`}
>
<Icon
src={ArrowBack}
className="mr-2"
/>
<FormattedMessage
id="bulk.email.back.to.instructorDashboard"
defaultMessage="Back to Instructor Dashboard"
description="A link to take the user back to the instructor dashboard"
/>
</Button>
);
return (
<Button
variant="tertiary"
className="mb-4.5 ml-n4.5 text-primary-500"
href={`${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor#view-course-info`}
>
<Icon
src={ArrowBack}
className="mr-2"
/>
<FormattedMessage
id="bulk.email.back.to.instructorDashboard"
defaultMessage="Back to Instructor Dashboard"
description="A link to take the user back to the instructor dashboard"
/>
</Button>
);
}
BackToInstructor.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default BackToInstructor;

View File

@@ -1,33 +0,0 @@
import React from 'react';
import BackToInstructor from './BackToInstructor';
import {
initializeMockApp, render, screen,
} from '../../setupTest';
describe('Testing BackToInstructor Component', () => {
beforeAll(async () => {
await initializeMockApp();
});
test('Render without Public path', async () => {
render(<BackToInstructor courseId="test-course-id" />);
const linkEl = await screen.findByText('Back to Instructor Dashboard');
expect(linkEl.href).toEqual('http://localhost:18000/courses/test-course-id/instructor#view-course-info');
});
test('Render with Public path', async () => {
Object.defineProperty(window, 'location', {
get() {
return { pathname: '/communications/courses/test-course-id/bulk-email' };
},
});
render(<BackToInstructor courseId="test-course-id" />);
const linkEl = await screen.findByText('Back to Instructor Dashboard');
expect(linkEl.href).toEqual('http://localhost:18000/courses/test-course-id/instructor#view-course-info');
expect(window.location.pathname).toEqual('/communications/courses/test-course-id/bulk-email');
});
});

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { Nav } from '@edx/paragon';
export default function NavigationTabs(props) {
const NavigationTabs = (props) => {
const { tabData } = props;
return (
@@ -19,7 +19,7 @@ export default function NavigationTabs(props) {
</Nav>
</div>
);
}
};
NavigationTabs.propTypes = {
tabData: PropTypes.arrayOf(PropTypes.shape({
@@ -32,3 +32,5 @@ NavigationTabs.propTypes = {
NavigationTabs.defaultProps = {
tabData: [],
};
export default NavigationTabs;

View File

@@ -12,7 +12,7 @@ import './PageContainer.scss';
export const CourseMetadataContext = React.createContext();
export default function PageContainer(props) {
const PageContainer = (props) => {
const { children } = props;
const { courseId } = useParams();
@@ -39,7 +39,7 @@ export default function PageContainer(props) {
}
const {
org, number, title, tabs, originalUserIsStaff, courseModes,
org, number, title, tabs, originalUserIsStaff,
} = metadataResponse;
const { cohorts } = cohortsResponse;
@@ -48,13 +48,12 @@ export default function PageContainer(props) {
number,
title,
originalUserIsStaff,
courseModes,
tabs: [...tabs],
cohorts: cohorts.map(({ name }) => name),
});
}
fetchCourseMetadata();
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (courseMetadata) {
@@ -67,11 +66,9 @@ export default function PageContainer(props) {
courseNumber={courseMetadata.number}
courseTitle={courseMetadata.title}
/>
<div className="pb-3 container">
<main>
{children}
</main>
</div>
<main>
{children}
</main>
<Footer />
</>
</CourseMetadataContext.Provider>
@@ -89,8 +86,10 @@ export default function PageContainer(props) {
/>
</div>
);
}
};
PageContainer.propTypes = {
children: PropTypes.node.isRequired,
};
export default PageContainer;

View File

@@ -1,10 +1,10 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export const getCourseHomeBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
const courseHomeBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
export async function getCourseHomeCourseMetadata(courseId) {
const courseHomeMetadataUrl = `${getCourseHomeBaseUrl()}/${courseId}`;
const courseHomeMetadataUrl = `${courseHomeBaseUrl}/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(courseHomeMetadataUrl);
return camelCaseObject(data);
}

View File

@@ -1,24 +0,0 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '../../../setupTest';
import * as api from './api';
import './__factories__/courseMetadata.factory';
describe('api', () => {
beforeAll(async () => {
await initializeMockApp();
});
test('getCourseHomeCourseMetadata', async () => {
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const courseMetadata = Factory.build('courseMetadata');
const { id: courseId } = courseMetadata;
axiosMock
.onGet(`${api.getCourseHomeBaseUrl()}/${courseId}`)
.reply(200, courseMetadata);
const data = await api.getCourseHomeCourseMetadata(courseId);
expect(data).toEqual(camelCaseObject(courseMetadata));
});
});

View File

@@ -1,7 +1,3 @@
import { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as headerMessages } from '@edx/frontend-component-header';
import { messages as paragonMessages } from '@edx/paragon';
import arMessages from './messages/ar.json';
import caMessages from './messages/ca.json';
// no need to import en messages-- they are in the defaultMessage field
@@ -17,7 +13,7 @@ import ruMessages from './messages/ru.json';
import thMessages from './messages/th.json';
import ukMessages from './messages/uk.json';
const appMessages = {
const messages = {
ar: arMessages,
'es-419': es419Messages,
fr: frMessages,
@@ -33,9 +29,4 @@ const appMessages = {
uk: ukMessages,
};
export default [
headerMessages,
footerMessages,
paragonMessages,
appMessages,
];
export default messages;

View File

@@ -2,14 +2,16 @@ import 'core-js/stable';
import 'regenerator-runtime/runtime';
import {
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, getConfig,
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig,
} from '@edx/frontend-platform';
import { AppProvider, AuthenticatedPageRoute, ErrorPage } from '@edx/frontend-platform/react';
import ReactDOM from 'react-dom';
import { Helmet } from 'react-helmet';
import { Routes, Route } from 'react-router-dom';
import messages from './i18n';
import { messages as headerMessages } from '@edx/frontend-component-header';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as paragonMessages } from '@edx/paragon';
import { Switch } from 'react-router-dom';
import appMessages from './i18n';
import './index.scss';
import BulkEmailTool from './components/bulk-email-tool';
@@ -18,21 +20,15 @@ import PageContainer from './components/page-container/PageContainer';
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
<Routes>
<Route
path="/courses/:courseId/bulk_email"
element={(
<AuthenticatedPageRoute>
<PageContainer>
<BulkEmailTool />
</PageContainer>
</AuthenticatedPageRoute>
)}
/>
</Routes>
<div className="pb-3 container">
<Switch>
<AuthenticatedPageRoute path="/courses/:courseId/bulk_email">
<PageContainer>
<BulkEmailTool />
</PageContainer>
</AuthenticatedPageRoute>
</Switch>
</div>
</AppProvider>,
document.getElementById('root'),
);
@@ -54,5 +50,5 @@ initialize({
);
},
},
messages,
messages: [appMessages, headerMessages, footerMessages, paragonMessages],
});

View File

@@ -8,12 +8,7 @@ import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform
import { configure as configureLogging, MockLoggingService } from '@edx/frontend-platform/logging';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { configure as configureAuth, MockAuthService } from '@edx/frontend-platform/auth';
import messages from './i18n';
jest.mock('@edx/frontend-platform/react/hooks', () => ({
...jest.requireActual('@edx/frontend-platform/react/hooks'),
useTrackColorSchemeChoice: jest.fn(),
}));
import appMessages from './i18n';
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -48,28 +43,21 @@ export function initializeMockApp() {
const i18nService = configureI18n({
config: getConfig(),
loggingService,
messages,
messages: [appMessages],
});
const authService = configureAuth(MockAuthService, { config: getConfig(), loggingService });
return { loggingService, i18nService, authService };
}
jest.mock('@edx/frontend-platform/react/hooks', () => ({
...jest.requireActual('@edx/frontend-platform/react/hooks'),
useTrackColorSchemeChoice: jest.fn(),
}));
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>
);
}
const Wrapper = ({ children }) => (
// eslint-disable-next-line react/jsx-filename-extension
<IntlProvider locale="en">
<AppProvider>{children}</AppProvider>
</IntlProvider>
);
return rtlRender(ui, { wrapper: Wrapper, ...options });
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
// NOTE: These are the breakpoints used in Bootstrap v4.0.0 as seen in
// the documentation (https://getbootstrap.com/docs/4.0/layout/overview/#responsive-breakpoints)
@@ -29,15 +29,15 @@ const breakpoints = {
*/
export default function useMobileResponsive(breakpoint) {
const [isMobileWindow, setIsMobileWindow] = useState();
const checkForMobile = () => {
const checkForMobile = useCallback(() => {
setIsMobileWindow(window.matchMedia(`(max-width: ${breakpoint || breakpoints.small.maxWidth}px)`).matches);
};
}, [breakpoint]);
useEffect(() => {
checkForMobile();
window.addEventListener('resize', checkForMobile);
// return this function here to clean up the event listener
return () => window.removeEventListener('resize', checkForMobile);
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return isMobileWindow;
}