Compare commits
151 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a7448850 | ||
|
|
1fd3343355 | ||
|
|
ef6ade6de1 | ||
|
|
7dc08c060f | ||
|
|
1e1f269b6b | ||
|
|
a8093439a9 | ||
|
|
10f906b72f | ||
|
|
3a14119d01 | ||
|
|
cb3b2b4670 | ||
|
|
9279a3e4ce | ||
|
|
2a5cf010f8 | ||
|
|
6dd835d63f | ||
|
|
87c1cb5bd2 | ||
|
|
5875631a6c | ||
|
|
176a95c352 | ||
|
|
b24971572d | ||
|
|
20f212501f | ||
|
|
852d0ef2d9 | ||
|
|
809112d4b2 | ||
|
|
d249b5e4aa | ||
|
|
dad6c84d46 | ||
|
|
5f61578e28 | ||
|
|
da30730662 | ||
|
|
6e4ae9d976 | ||
|
|
e863eef77d | ||
|
|
ff900335a1 | ||
|
|
c0dc0cbad6 | ||
|
|
83aec7318e | ||
|
|
a037f5b340 | ||
|
|
91402fbf12 | ||
|
|
7a299eb064 | ||
|
|
90e8eeeb50 | ||
|
|
f25ce0b95d | ||
|
|
98db1a4a35 | ||
|
|
cf4b632c55 | ||
|
|
0720b2feae | ||
|
|
56e1781004 | ||
|
|
83eb21bb9a | ||
|
|
18ddb35e1e | ||
|
|
ae93b68d57 | ||
|
|
7ce2a61233 | ||
|
|
2de12997ce | ||
|
|
f6c8cfa906 | ||
|
|
db181a7945 | ||
|
|
840449f71b | ||
|
|
01f1793414 | ||
|
|
ce45c8012d | ||
|
|
7224785682 | ||
|
|
6d27b7082d | ||
|
|
c90bdb1ac7 | ||
|
|
090968e2f2 | ||
|
|
765857f380 | ||
|
|
0eec29a458 | ||
|
|
c2b4d04b5d | ||
|
|
a198557e67 | ||
|
|
4493eb71fa | ||
|
|
559a1061b6 | ||
|
|
53fa594207 | ||
|
|
0c34d86610 | ||
|
|
05d16cf231 | ||
|
|
55a0ddb8d2 | ||
|
|
344b68e10e | ||
|
|
631d47b286 | ||
|
|
81a5f89f36 | ||
|
|
fd3a49d7c6 | ||
|
|
a2b2d55db0 | ||
|
|
fca2cce77c | ||
|
|
0dc2e65f60 | ||
|
|
cddc28c34f | ||
|
|
56ca914fb4 | ||
|
|
1ea43e0ad4 | ||
|
|
9d25d6e4d0 | ||
|
|
2888cb6662 | ||
|
|
418c78d1f3 | ||
|
|
4231093347 | ||
|
|
b5b90272f8 | ||
|
|
a08d30fbbb | ||
|
|
ece65c83ad | ||
|
|
fd98b4468e | ||
|
|
4a8df3b50e | ||
|
|
35f755ccf1 | ||
|
|
6b4bd3b534 | ||
|
|
2d9d195936 | ||
|
|
1082b27647 | ||
|
|
9782cf108f | ||
|
|
466fac7e9e | ||
|
|
422632c582 | ||
|
|
67b6512288 | ||
|
|
13ba06fd2a | ||
|
|
61a2a4e8c9 | ||
|
|
e112c3a6d1 | ||
|
|
97a21b9574 | ||
|
|
22675fd17a | ||
|
|
32327cde93 | ||
|
|
dadbfed8e1 | ||
|
|
262ea5be0d | ||
|
|
fcb393d9e7 | ||
|
|
a6e84bf56c | ||
|
|
9cf76fec74 | ||
|
|
0e22884a34 | ||
|
|
1c0f1d6db9 | ||
|
|
c5a2070b6e | ||
|
|
6ac242aa0a | ||
|
|
0130b5279f | ||
|
|
d40450dabf | ||
|
|
61245d4423 | ||
|
|
aec96cd652 | ||
|
|
2c146b516f | ||
|
|
70d5c0e112 | ||
|
|
15325f0eb1 | ||
|
|
25bb72b53b | ||
|
|
0ef0cee7dc | ||
|
|
8e2cdb1b25 | ||
|
|
f8212c24c3 | ||
|
|
fcad43c485 | ||
|
|
bc64c9e278 | ||
|
|
64d532aad5 | ||
|
|
d05ed5ba26 | ||
|
|
44e122142c | ||
|
|
937f7a17c6 | ||
|
|
905dd98d28 | ||
|
|
2927f938eb | ||
|
|
156dd0e47b | ||
|
|
dcf69d693f | ||
|
|
c905ede6fe | ||
|
|
eea663675b | ||
|
|
9c6644c1b9 | ||
|
|
8205ff64f8 | ||
|
|
c0f41874bb | ||
|
|
339f9b303f | ||
|
|
aec97816fb | ||
|
|
b62a92c4d2 | ||
|
|
c950864afa | ||
|
|
31669aa1f2 | ||
|
|
deae6c9654 | ||
|
|
1ede234b31 | ||
|
|
3deaeece2c | ||
|
|
b7275c1491 | ||
|
|
f6058c9bbe | ||
|
|
5a20359856 | ||
|
|
99b9a51598 | ||
|
|
5d88b19c07 | ||
|
|
9e29c54ac6 | ||
|
|
298f573113 | ||
|
|
6dd09451a3 | ||
|
|
ffa0361c22 | ||
|
|
e09bb55544 | ||
|
|
5ce9ab00c8 | ||
|
|
200f19dd9a | ||
|
|
a37d63a4a3 | ||
|
|
d67348929d |
3
.env
3
.env
@@ -18,3 +18,6 @@ REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
SCHEDULE_EMAIL_SECTION=''
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
|
||||
@@ -19,3 +19,6 @@ 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'
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
|
||||
@@ -17,3 +17,6 @@ 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'
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
|
||||
10
.eslintrc.js
10
.eslintrc.js
@@ -1,3 +1,9 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint', {
|
||||
rules: {
|
||||
'react/function-component-definition': 'off',
|
||||
},
|
||||
});
|
||||
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1 +0,0 @@
|
||||
* @edx/community-engineering
|
||||
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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 }}
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -11,14 +11,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [12, 14, 16]
|
||||
node: [18, 20]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- name: Install dependencies
|
||||
@@ -33,7 +36,8 @@ jobs:
|
||||
run: npm run build
|
||||
- name: i18n_extract
|
||||
run: npm run i18n_extract
|
||||
- name: is-es5
|
||||
run: npm run is-es5
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,4 @@ on:
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: edx/.github/.github/workflows/commitlint.yml@master
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
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 }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ temp/babel-plugin-react-intl
|
||||
*~
|
||||
/temp
|
||||
/.vscode
|
||||
src/i18n/messages/
|
||||
|
||||
34
Makefile
34
Makefile
@@ -1,21 +1,17 @@
|
||||
transifex_resource = frontend-app-communications
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
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
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements:
|
||||
npm install
|
||||
npm ci
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -33,20 +29,18 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
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/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)
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
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-platform/src/i18n/messages:frontend-platform \
|
||||
translations/frontend-app-communications/src/i18n/messages:frontend-app-communications
|
||||
|
||||
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-platform frontend-app-communications
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
163
README.rst
163
README.rst
@@ -1,55 +1,62 @@
|
||||
|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
|
||||
==============================
|
||||
###########################
|
||||
|
||||
Please tag **edx-aperture** on any PRs or issues. Thanks!
|
||||
|license-badge| |status-badge| |ci-badge| |codecov-badge|
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
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.
|
||||
Purpose
|
||||
*******
|
||||
|
||||
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.
|
||||
For now, this repo is not integrated with devstack. You'll be running the app locally and not through docker. This does make setup a little easier.
|
||||
|
||||
1. Clone the repo into your usual workspace
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
.. code-block::
|
||||
1. Clone your new repo:
|
||||
|
||||
mkdir -p ~/workspace/
|
||||
cd ~/workspace/
|
||||
git clone https://github.com/edx/frontend-app-communications.git
|
||||
.. code-block:: bash
|
||||
|
||||
2. Install frontend dependencies
|
||||
git clone https://github.com/edx/frontend-app-communications.git
|
||||
|
||||
.. code-block::
|
||||
2. Use node v18.x.
|
||||
|
||||
npm i
|
||||
The current version of the micro-frontend build scripts supports node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes a ``.nvmrc`` file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
|
||||
3. Start the devserver. The app will be running at ``localhost:1984``, or whatever port you change it too.
|
||||
3. Install npm dependencies:
|
||||
|
||||
.. code-block::
|
||||
.. code-block:: bash
|
||||
|
||||
cd frontend-app-communications && npm install
|
||||
|
||||
4. Update the application port to use for local development:
|
||||
|
||||
The 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:: bash
|
||||
|
||||
npm start
|
||||
|
||||
|
||||
Environment Variables/Setup Notes
|
||||
---------------------------------
|
||||
|
||||
If you wish to add new environment varibles for local testing, they should be listed in 2 places:
|
||||
If you wish to add new environment variables for local testing, they should be listed in 2 places:
|
||||
|
||||
1. In ``.env.development``
|
||||
2. Added to the ``mergeConfig`` found in ``src/index.jsx``
|
||||
|
||||
.. code-block::
|
||||
.. code-block:: jsx
|
||||
|
||||
initialize({
|
||||
config: () => {
|
||||
@@ -58,10 +65,108 @@ If you wish to add new environment varibles for local testing, they should be li
|
||||
}, 'CommuncationsAppConfig');
|
||||
|
||||
Running Tests
|
||||
---------------------------
|
||||
-------------
|
||||
|
||||
Tests use `jest` and `react-test-library`. To run all the tests for this repo:
|
||||
|
||||
.. code-block::
|
||||
.. code-block::
|
||||
|
||||
npm test
|
||||
npm test
|
||||
|
||||
Plugins
|
||||
=======
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
**Production Build**
|
||||
|
||||
The production build is created with ``npm run build``.
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
|
||||
Please 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
|
||||
|
||||
18
catalog-info.yaml
Normal file
18
catalog-info.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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:committers-frontend
|
||||
type: "service"
|
||||
lifecycle: "production"
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
|
||||
|
||||
@@ -23,9 +23,9 @@ module.exports = {
|
||||
**********************************************************************************************/
|
||||
|
||||
// { moduleName: '@edx/brand', dir: '../brand-openedx' }, // replace with your brand checkout
|
||||
// { moduleName: '@edx/paragon/scss/core', dir: '../paragon', dist: 'scss/core' },
|
||||
// { moduleName: '@edx/paragon/icons', dir: '../paragon', dist: 'icons' },
|
||||
// { moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
|
||||
// { moduleName: '@openedx/paragon/scss/core', dir: '../paragon', dist: 'scss/core' },
|
||||
// { moduleName: '@openedx/paragon/icons', dir: '../paragon', dist: 'icons' },
|
||||
// { moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' },
|
||||
// { moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
|
||||
],
|
||||
};
|
||||
|
||||
61363
package-lock.json
generated
61363
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
73
package.json
73
package.json
@@ -7,18 +7,16 @@
|
||||
"url": "git+https://github.com/edx/frontend-app-communications.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
@@ -35,42 +33,47 @@
|
||||
"url": "https://github.com/edx/frontend-app-communications/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.1.6",
|
||||
"@edx/frontend-component-header": "^2.4.2",
|
||||
"@edx/frontend-platform": "1.12.7",
|
||||
"@edx/paragon": "16.3.2",
|
||||
"@edx/tinymce-language-selector": "^1.1.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-header": "^5.6.0",
|
||||
"@edx/frontend-platform": "^8.0.0",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@edx/tinymce-language-selector": "1.1.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.1.16",
|
||||
"@tinymce/tinymce-react": "^3.13.0",
|
||||
"classnames": "^2.3.1",
|
||||
"core-js": "3.15.2",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-redux": "7.2.6",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.9",
|
||||
"tinymce": "^5.10.2"
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.1.2",
|
||||
"@openedx/frontend-slot-footer": "^1.0.2",
|
||||
"@openedx/paragon": "^22.0.0",
|
||||
"@tinymce/tinymce-react": "3.14.0",
|
||||
"axios": "0.27.2",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.26.1",
|
||||
"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-redux": "7.2.9",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"redux": "4.2.0",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"tinymce": "5.10.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "8.1.6",
|
||||
"axios-mock-adapter": "^1.20.0",
|
||||
"codecov": "3.8.3",
|
||||
"es-check": "6.1.1",
|
||||
"glob": "7.2.0",
|
||||
"@edx/browserslist-config": "^1.2.0",
|
||||
"@openedx/frontend-build": "14.0.3",
|
||||
"@edx/reactifex": "^2.1.1",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"axios-mock-adapter": "1.21.2",
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"jest": "27.3.1",
|
||||
"prettier": "^2.5.1",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "^2.1.0",
|
||||
"@testing-library/jest-dom": "^5.16.1",
|
||||
"@testing-library/react": "^12.1.2"
|
||||
"jest": "29.7.0",
|
||||
"prettier": "2.8.1",
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"pin"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"timezone": "America/New_York"
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
import React, { useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { Container } from '@openedx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
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';
|
||||
import { BulkEmailProvider } from './bulk-email-context';
|
||||
import BackToInstructor from '../navigation-tabs/BackToInstructor';
|
||||
|
||||
export default function BulkEmailTool() {
|
||||
const { courseId } = useParams();
|
||||
|
||||
const isMobile = useMobileResponsive();
|
||||
const textEditorRef = useRef();
|
||||
|
||||
const copyTextToEditor = (body) => {
|
||||
if (textEditorRef?.current) {
|
||||
textEditorRef.current.setContent(body);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CourseMetadataContext.Consumer>
|
||||
{(courseMetadata) => (courseMetadata.isStaff ? (
|
||||
<div>
|
||||
{(courseMetadata) => (courseMetadata.originalUserIsStaff ? (
|
||||
<>
|
||||
<NavigationTabs courseId={courseId} tabData={courseMetadata.tabs} />
|
||||
<div className={classnames({ 'border border-primary-200': !isMobile })}>
|
||||
<div className="row">
|
||||
<BulkEmailForm courseId={courseId} cohorts={courseMetadata.cohorts} editorRef={textEditorRef} />
|
||||
</div>
|
||||
<div className="row">
|
||||
<BulkEmailTaskManager courseId={courseId} copyTextToEditor={copyTextToEditor} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BulkEmailProvider>
|
||||
<Container size="md">
|
||||
<BackToInstructor courseId={courseId} />
|
||||
<div className="row pb-4.5">
|
||||
<h1 className="text-primary-500">
|
||||
<FormattedMessage
|
||||
id="bulk.email.send.email.header"
|
||||
defaultMessage="Send an email"
|
||||
description="A label for email form"
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<BulkEmailForm
|
||||
courseId={courseId}
|
||||
cohorts={courseMetadata.cohorts}
|
||||
courseModes={courseMetadata.courseModes}
|
||||
/>
|
||||
</div>
|
||||
<div className="row py-5">
|
||||
<BulkEmailTaskManager courseId={courseId} />
|
||||
</div>
|
||||
</Container>
|
||||
</BulkEmailProvider>
|
||||
</>
|
||||
) : (
|
||||
<ErrorPage />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable react/jsx-no-constructed-context-values */
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import useAsyncReducer, { combineReducers } from '../../../utils/useAsyncReducer';
|
||||
import editor, { editorInitialState } from '../bulk-email-form/data/reducer';
|
||||
import scheduledEmailsTable, {
|
||||
scheduledEmailsTableInitialState,
|
||||
} from '../bulk-email-task-manager/bulk-email-scheduled-emails-table/data/reducer';
|
||||
|
||||
export const BulkEmailContext = React.createContext();
|
||||
|
||||
export default function BulkEmailProvider({ children }) {
|
||||
const initialState = {
|
||||
editor: editorInitialState,
|
||||
scheduledEmailsTable: scheduledEmailsTableInitialState,
|
||||
};
|
||||
const [state, dispatch] = useAsyncReducer(
|
||||
combineReducers({ editor, scheduledEmailsTable }),
|
||||
initialState,
|
||||
);
|
||||
return <BulkEmailContext.Provider value={[state, dispatch]}>{children}</BulkEmailContext.Provider>;
|
||||
}
|
||||
|
||||
BulkEmailProvider.propTypes = {
|
||||
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default as BulkEmailProvider, BulkEmailContext } from './BulkEmailProvider';
|
||||
@@ -1,112 +1,269 @@
|
||||
import React, { useState } from 'react';
|
||||
/* eslint-disable react/no-unstable-nested-components */
|
||||
import React, { useContext, 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';
|
||||
Button,
|
||||
Form, Icon, StatefulButton, Toast, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
SpinnerSimple, Cancel, Send, Event, Check,
|
||||
} from '@openedx/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 TaskAlertModal from '../task-alert-modal';
|
||||
import useTimeout from '../../../utils/useTimeout';
|
||||
import useMobileResponsive from '../../../utils/useMobileResponsive';
|
||||
import ScheduleEmailForm from './ScheduleEmailForm';
|
||||
import messages from './messages';
|
||||
import { BulkEmailContext } from '../bulk-email-context';
|
||||
import {
|
||||
addRecipient,
|
||||
clearEditor,
|
||||
clearErrorState,
|
||||
handleEditorChange,
|
||||
removeRecipient,
|
||||
} from './data/actions';
|
||||
import { editScheduledEmailThunk, postBulkEmailThunk } from './data/thunks';
|
||||
import { getScheduledBulkEmailThunk } from '../bulk-email-task-manager/bulk-email-scheduled-emails-table/data/thunks';
|
||||
|
||||
import './bulkEmailForm.scss';
|
||||
|
||||
export const FORM_SUBMIT_STATES = {
|
||||
DEFAULT: 'default',
|
||||
PENDING: 'pending',
|
||||
COMPLETE: 'complete',
|
||||
COMPLETED_DEFAULT: 'completed_default',
|
||||
COMPLETE_SCHEDULE: 'completed_schedule',
|
||||
SCHEDULE: 'schedule',
|
||||
RESCHEDULE: 'reschedule',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
export default function BulkEmailForm(props) {
|
||||
const { courseId, cohorts, editorRef } = props;
|
||||
const [subject, setSubject] = useState('');
|
||||
const FORM_ACTIONS = {
|
||||
POST: 'POST',
|
||||
PATCH: 'PATCH',
|
||||
};
|
||||
|
||||
function BulkEmailForm(props) {
|
||||
const {
|
||||
courseId,
|
||||
cohorts,
|
||||
courseModes,
|
||||
intl,
|
||||
} = props;
|
||||
const [{ editor }, dispatch] = useContext(BulkEmailContext);
|
||||
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 resetEmailForm = useTimeout(() => {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETED_DEFAULT);
|
||||
}, 3000);
|
||||
const [isScheduled, toggleScheduled] = useState(false);
|
||||
const isMobile = useMobileResponsive();
|
||||
|
||||
/**
|
||||
* Since we are working with both an old and new API endpoint, the body for the POST
|
||||
* and the PATCH have different signatures. Therefore, based on the action required, we need to
|
||||
* format the data properly to be accepted on the back end.
|
||||
* @param {*} action "POST" or "PATCH" of the FORM_ACTIONS constant
|
||||
* @returns formatted Data
|
||||
*/
|
||||
const formatDataForFormAction = (action) => {
|
||||
if (action === FORM_ACTIONS.POST) {
|
||||
const emailData = new FormData();
|
||||
emailData.append('action', 'send');
|
||||
emailData.append('send_to', JSON.stringify(editor.emailRecipients));
|
||||
emailData.append('subject', editor.emailSubject);
|
||||
emailData.append('message', editor.emailBody);
|
||||
if (isScheduled) {
|
||||
emailData.append('schedule', new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toISOString());
|
||||
}
|
||||
return emailData;
|
||||
}
|
||||
if (action === FORM_ACTIONS.PATCH) {
|
||||
return {
|
||||
email: {
|
||||
targets: editor.emailRecipients,
|
||||
subject: editor.emailSubject,
|
||||
message: editor.emailBody,
|
||||
id: editor.emailId,
|
||||
},
|
||||
schedule: isScheduled ? new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toISOString() : null,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* This function resets the form based on what state the form is currently in. Used after
|
||||
* successfully sending or scheduling and email, or on error.
|
||||
*
|
||||
* @param {Boolean} error If true, resets just the state of the form, and not the editor.
|
||||
* if false, reset the form completely, and wipe all email data form the form.
|
||||
*/
|
||||
const resetEmailForm = (error) => {
|
||||
if (error) {
|
||||
dispatch(clearErrorState());
|
||||
} else {
|
||||
dispatch(clearEditor());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Allows for a delayed form reset, to give the user time to process completion and error
|
||||
* states before reseting the form.
|
||||
*/
|
||||
const delayedEmailFormReset = useTimeout(
|
||||
() => resetEmailForm(editor.errorRetrievingData),
|
||||
3000,
|
||||
);
|
||||
|
||||
const onFormChange = (event) => dispatch(handleEditorChange(event.target.name, event.target.value));
|
||||
|
||||
const onRecipientChange = (event) => {
|
||||
if (event.target.checked) {
|
||||
add(event.target.value);
|
||||
dispatch(addRecipient(event.target.value));
|
||||
// if "All Learners" is checked then we want to remove any cohorts, verified learners, and audit learners
|
||||
if (event.target.value === 'learners') {
|
||||
editor.emailRecipients.forEach(recipient => {
|
||||
if (/^cohort/.test(recipient) || /^track/.test(recipient)) {
|
||||
dispatch(removeRecipient(recipient));
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
remove(event.target.value);
|
||||
dispatch(removeRecipient(event.target.value));
|
||||
}
|
||||
};
|
||||
const onInit = (event, editor) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
const validateDateTime = (date, time) => {
|
||||
if (isScheduled) {
|
||||
const now = new Date();
|
||||
const newSchedule = new Date(`${editor.scheduleDate} ${editor.scheduleTime}`);
|
||||
return !!date && !!time && newSchedule > now;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const onSubjectChange = (event) => setSubject(event.target.value);
|
||||
|
||||
const validateEmailForm = () => {
|
||||
const subjectValid = subject.length !== 0;
|
||||
const bodyValid = editorRef.current.getContent().length !== 0;
|
||||
const recipientsValid = selectedRecipients.length !== 0;
|
||||
|
||||
const subjectValid = editor.emailSubject.length !== 0;
|
||||
const bodyValid = editor.emailBody.length !== 0;
|
||||
const recipientsValid = editor.emailRecipients.length !== 0;
|
||||
const scheduleValid = validateDateTime(editor.scheduleDate, editor.scheduleTime);
|
||||
setEmailFormValidation({
|
||||
subject: subjectValid,
|
||||
recipients: recipientsValid,
|
||||
body: bodyValid,
|
||||
schedule: scheduleValid,
|
||||
});
|
||||
|
||||
return subjectValid && bodyValid && recipientsValid;
|
||||
return subjectValid && bodyValid && recipientsValid && scheduleValid;
|
||||
};
|
||||
|
||||
const createEmailTask = async () => {
|
||||
const emailData = new FormData();
|
||||
if (validateEmailForm()) {
|
||||
setEmailFormStatus(() => FORM_SUBMIT_STATES.PENDING);
|
||||
emailData.append('action', 'send');
|
||||
emailData.append('send_to', JSON.stringify(selectedRecipients));
|
||||
emailData.append('subject', subject);
|
||||
emailData.append('message', editorRef.current.getContent());
|
||||
let data;
|
||||
try {
|
||||
data = await postBulkEmail(emailData, courseId);
|
||||
} catch (e) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.ERROR);
|
||||
return;
|
||||
}
|
||||
if (data.status === 200) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE);
|
||||
resetEmailForm();
|
||||
if (editor.editMode) {
|
||||
const editedEmail = formatDataForFormAction(FORM_ACTIONS.PATCH);
|
||||
await dispatch(editScheduledEmailThunk(editedEmail, courseId, editor.schedulingId));
|
||||
} else {
|
||||
const emailData = formatDataForFormAction(FORM_ACTIONS.POST);
|
||||
await dispatch(postBulkEmailThunk(emailData, courseId));
|
||||
}
|
||||
dispatch(getScheduledBulkEmailThunk(courseId, 1));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* State manager for the various states the form can be in at any given time.
|
||||
* The states of the form are based off various pieces of the editor store, and
|
||||
* calculates what state and whether to reset the form based on these booleans.
|
||||
* Any time the form needs to change state, the conditions for that state change should
|
||||
* placed here to prevent unecessary rerenders and implicit/flakey state update batching.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (editor.isLoading) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.PENDING);
|
||||
return;
|
||||
}
|
||||
if (editor.errorRetrievingData) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.ERROR);
|
||||
delayedEmailFormReset();
|
||||
return;
|
||||
}
|
||||
if (editor.formComplete) {
|
||||
if (isScheduled) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE_SCHEDULE);
|
||||
} else {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE);
|
||||
}
|
||||
delayedEmailFormReset();
|
||||
return;
|
||||
}
|
||||
if (editor.editMode === true) {
|
||||
toggleScheduled(true);
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.RESCHEDULE);
|
||||
} else if (isScheduled) {
|
||||
setEmailFormStatus(FORM_SUBMIT_STATES.SCHEDULE);
|
||||
} 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-100 m-auto p-lg-4 py-2.5 px-5">
|
||||
<div className={classNames('w-100 m-auto', !isMobile && 'p-4 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>
|
||||
<ul className="list-unstyled">
|
||||
{selectedRecipients.map((group) => (
|
||||
<li key={group}>{group}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
alertMessage={editor.editMode ? EditMessage() : AlertMessage()}
|
||||
close={(event) => {
|
||||
closeTaskAlert();
|
||||
if (event.target.name === 'continue') {
|
||||
@@ -115,127 +272,113 @@ 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>
|
||||
<BulkEmailRecipient
|
||||
selectedGroups={selectedRecipients}
|
||||
selectedGroups={editor.emailRecipients}
|
||||
handleCheckboxes={onRecipientChange}
|
||||
additionalCohorts={cohorts}
|
||||
isValid={emailFormValidation.recipients}
|
||||
courseModes={courseModes}
|
||||
/>
|
||||
<Form.Group controlId="emailSubject">
|
||||
<Form.Label>
|
||||
<FormattedMessage
|
||||
id="bulk.email.subject.label"
|
||||
defaultMessage="Subject:"
|
||||
description="Email subject line input label. Meant to have colon or equivilant punctuation."
|
||||
/>
|
||||
</Form.Label>
|
||||
<Form.Control name="subject" className="w-lg-50" onChange={onSubjectChange} />
|
||||
<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>
|
||||
{!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>
|
||||
<TextEditor onInit={onInit} />
|
||||
<Form.Label className="h3 text-primary-500">{intl.formatMessage(messages.bulkEmailBodyLabel)}</Form.Label>
|
||||
<TextEditor onChange={(value) => dispatch(handleEditorChange('emailBody', value))} value={editor.emailBody} />
|
||||
{!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={onFormChange}
|
||||
dateTime={{ date: editor.scheduleDate, time: editor.scheduleTime }}
|
||||
/>
|
||||
</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 align-items-end': !isMobile,
|
||||
'border-top pt-2': isScheduled,
|
||||
})}
|
||||
>
|
||||
{editor.editMode && <Button className="ml-2" variant="outline-brand" onClick={() => dispatch(clearEditor())}>Cancel</Button>}
|
||||
<StatefulButton
|
||||
className="send-email-btn"
|
||||
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.RESCHEDULE]: <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.RESCHEDULE]: intl.formatMessage(messages.bulkEmailSubmitButtonReschedule),
|
||||
[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,
|
||||
]}
|
||||
/>
|
||||
<Toast
|
||||
show={
|
||||
emailFormStatus === FORM_SUBMIT_STATES.ERROR
|
||||
|| emailFormStatus === FORM_SUBMIT_STATES.COMPLETE
|
||||
|| emailFormStatus === FORM_SUBMIT_STATES.COMPLETE_SCHEDULE
|
||||
}
|
||||
onClose={() => resetEmailForm(emailFormStatus === FORM_SUBMIT_STATES.ERROR)}
|
||||
>
|
||||
{emailFormStatus === FORM_SUBMIT_STATES.ERROR && intl.formatMessage(messages.bulkEmailFormError)}
|
||||
{emailFormStatus === FORM_SUBMIT_STATES.COMPLETE && intl.formatMessage(messages.bulkEmailFormSuccess)}
|
||||
{emailFormStatus === FORM_SUBMIT_STATES.COMPLETE_SCHEDULE
|
||||
&& intl.formatMessage(messages.bulkEmailFormScheduledSuccess)}
|
||||
</Toast>
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</div>
|
||||
@@ -249,6 +392,13 @@ BulkEmailForm.defaultProps = {
|
||||
BulkEmailForm.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
cohorts: PropTypes.arrayOf(PropTypes.string),
|
||||
editorRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(Element) })])
|
||||
.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
courseModes: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
slug: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BulkEmailForm);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@openedx/paragon';
|
||||
import useMobileResponsive from '../../../utils/useMobileResponsive';
|
||||
|
||||
function 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')}>
|
||||
<div className="w-md-50 mx-2">
|
||||
<Form.Control
|
||||
type="date"
|
||||
name="scheduleDate"
|
||||
data-testid="scheduleDate"
|
||||
onChange={onDateTimeChange}
|
||||
value={date}
|
||||
floatingLabel={(
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.schedule.date"
|
||||
defaultMessage="Send date"
|
||||
description="Label for the date portion of the email schedule form"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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(),
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
<div className="w-md-50 mx-2">
|
||||
<Form.Control
|
||||
type="time"
|
||||
name="scheduleTime"
|
||||
data-testid="scheduleTime"
|
||||
onChange={onDateTimeChange}
|
||||
value={time}
|
||||
floatingLabel={(
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.schedule.time"
|
||||
defaultMessage="Send time"
|
||||
description="Label for the time portion of the email schedule form"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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' }),
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</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, and must be a date in the future"
|
||||
description="An error message located under the date-time selector. Visible only on failure."
|
||||
/>
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
ScheduleEmailForm.defaultProps = {
|
||||
dateTime: {
|
||||
date: '',
|
||||
time: '',
|
||||
},
|
||||
};
|
||||
|
||||
ScheduleEmailForm.propTypes = {
|
||||
isValid: PropTypes.bool.isRequired,
|
||||
onDateTimeChange: PropTypes.func.isRequired,
|
||||
dateTime: PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
time: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
export default ScheduleEmailForm;
|
||||
@@ -1,8 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export async function postBulkEmail(email, courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor/api/send_email`;
|
||||
return getAuthenticatedHttpClient().post(url, email);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Form } from '@edx/paragon';
|
||||
import { Form } from '@openedx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import './bulkEmailRecepient.scss';
|
||||
@@ -14,62 +14,88 @@ const DEFAULT_GROUPS = {
|
||||
};
|
||||
|
||||
export default function BulkEmailRecipient(props) {
|
||||
const { handleCheckboxes, selectedGroups, additionalCohorts } = props;
|
||||
const {
|
||||
handleCheckboxes,
|
||||
selectedGroups,
|
||||
additionalCohorts,
|
||||
courseModes,
|
||||
} = props;
|
||||
const hasCourseModes = courseModes && courseModes.length > 1;
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.recipients.sendLabel"
|
||||
defaultMessage="Send To:"
|
||||
description="A label before the list of potential recipients"
|
||||
/>
|
||||
<span className="h3 text-primary-500">
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.recipients.sendLabel"
|
||||
defaultMessage="Send to"
|
||||
description="A label before the list of potential recipients"
|
||||
/>
|
||||
</span>
|
||||
</Form.Label>
|
||||
<Form.CheckboxSet
|
||||
name="recipientGroups"
|
||||
className="flex-wrap flex-row recipient-groups w-75"
|
||||
className="flex-wrap flex-row recipient-groups w-100"
|
||||
onChange={handleCheckboxes}
|
||||
value={selectedGroups}
|
||||
>
|
||||
<Form.Checkbox key="myself" value="myself" className="mt-2.5">
|
||||
<Form.Checkbox key="myself" value="myself" className="mt-2.5 col col-lg-4 col-sm-6 col-12">
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.recipients.myself"
|
||||
defaultMessage="Myself"
|
||||
description="A selectable choice from a list of potential email recipients"
|
||||
/>
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox key="staff" value="staff">
|
||||
<Form.Checkbox
|
||||
key="staff"
|
||||
value="staff"
|
||||
className="col col-lg-4 col-sm-6 col-12"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.recipients.staff"
|
||||
defaultMessage="Staff/Administrators"
|
||||
description="A selectable choice from a list of potential email recipients"
|
||||
/>
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
key="track:audit"
|
||||
value="track:audit"
|
||||
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.recipients.audit"
|
||||
defaultMessage="Learners in the audit track"
|
||||
description="A selectable choice from a list of potential email recipients"
|
||||
/>
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
key="track:verified"
|
||||
value="track:verified"
|
||||
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.recipients.verified"
|
||||
defaultMessage="Learners in the verified certificate track"
|
||||
description="A selectable choice from a list of potential email recipients"
|
||||
/>
|
||||
</Form.Checkbox>
|
||||
{
|
||||
// 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>
|
||||
))
|
||||
}
|
||||
{
|
||||
// additional cohorts
|
||||
additionalCohorts
|
||||
&& additionalCohorts.map((cohort) => (
|
||||
<Form.Checkbox
|
||||
key={cohort}
|
||||
value={`cohort:${cohort}`}
|
||||
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
|
||||
className="col col-lg-4 col-sm-6 col-12"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.cohort.label"
|
||||
defaultMessage="Cohort: {cohort}"
|
||||
values={{ cohort }}
|
||||
/>
|
||||
</Form.Checkbox>
|
||||
))
|
||||
}
|
||||
<Form.Checkbox
|
||||
key="learners"
|
||||
value="learners"
|
||||
disabled={selectedGroups.find((group) => group === (DEFAULT_GROUPS.AUDIT || DEFAULT_GROUPS.VERIFIED))}
|
||||
className="col col-lg-4 col-sm-6 col-12"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.recipients.learners"
|
||||
@@ -77,22 +103,6 @@ export default function BulkEmailRecipient(props) {
|
||||
description="A selectable choice from a list of potential email recipients"
|
||||
/>
|
||||
</Form.Checkbox>
|
||||
{
|
||||
// additional cohorts
|
||||
additionalCohorts
|
||||
&& additionalCohorts.map((cohort) => (
|
||||
<Form.Checkbox
|
||||
key={cohort}
|
||||
value={`cohort:${cohort}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="bulk.email.form.cohort.label"
|
||||
defaultMessage="Cohort: {cohort}"
|
||||
values={{ cohort }}
|
||||
/>
|
||||
</Form.Checkbox>
|
||||
))
|
||||
}
|
||||
</Form.CheckboxSet>
|
||||
{!props.isValid && (
|
||||
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
|
||||
@@ -117,4 +127,10 @@ 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,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.recipient-groups {
|
||||
> div {
|
||||
min-width: 50%;
|
||||
padding-right: 0.5rem;
|
||||
input {
|
||||
padding: 0.5rem !important;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export { default } from './BulkEmailRecipient';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Flip a leading icon to be a trailing icon
|
||||
.send-email-btn {
|
||||
> span {
|
||||
flex-direction: row-reverse;
|
||||
gap: 0.5rem;
|
||||
> span {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
export default Factory.define('')
|
||||
.attr('cohorts', [
|
||||
'Gnarly',
|
||||
'Righteous',
|
||||
]);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,74 @@
|
||||
export const handleEditorChange = (fieldName, fieldValue) => ({
|
||||
type: 'EDITOR_ON_CHANGE',
|
||||
payload: {
|
||||
[fieldName]: fieldValue,
|
||||
},
|
||||
});
|
||||
|
||||
export const clearEditor = () => ({
|
||||
type: 'CLEAR_EDITOR',
|
||||
});
|
||||
|
||||
export const clearErrorState = () => ({
|
||||
type: 'CLEAR_ERROR',
|
||||
});
|
||||
|
||||
export const copyToEditor = (payload) => ({
|
||||
type: 'COPY_TO_EDITOR',
|
||||
payload,
|
||||
});
|
||||
|
||||
export const addRecipient = (payload) => ({
|
||||
type: 'ADD_RECIPIENT',
|
||||
payload,
|
||||
});
|
||||
|
||||
export const removeRecipient = (payload) => ({
|
||||
type: 'REMOVE_RECIPIENT',
|
||||
payload,
|
||||
});
|
||||
|
||||
export const clearRecipients = () => ({
|
||||
type: 'CLEAR_RECIPIENTS',
|
||||
});
|
||||
|
||||
export const clearDateTime = () => ({
|
||||
type: 'CLEAR_DATE_TIME',
|
||||
});
|
||||
|
||||
export const setEditMode = (editMode = false) => ({
|
||||
type: 'SET_EDIT_MODE',
|
||||
payload: editMode,
|
||||
});
|
||||
|
||||
export const patchScheduledEmail = () => ({
|
||||
type: 'PATCH_SCHEDULED_EMAIL',
|
||||
});
|
||||
|
||||
export const patchScheduledEmailStart = () => ({
|
||||
type: 'PATCH_START',
|
||||
});
|
||||
|
||||
export const patchScheduledEmailComplete = () => ({
|
||||
type: 'PATCH_COMPLETE',
|
||||
});
|
||||
|
||||
export const patchScheduledEmailError = () => ({
|
||||
type: 'PATCH_FAILURE',
|
||||
});
|
||||
|
||||
export const postBulkEmail = () => ({
|
||||
type: 'POST_BULK_EMAIL',
|
||||
});
|
||||
|
||||
export const postBulkEmailStart = () => ({
|
||||
type: 'POST_START',
|
||||
});
|
||||
|
||||
export const postBulkEmailComplete = () => ({
|
||||
type: 'POST_COMPLETE',
|
||||
});
|
||||
|
||||
export const postBulkEmailError = () => ({
|
||||
type: 'POST_FAILURE',
|
||||
});
|
||||
26
src/components/bulk-email-tool/bulk-email-form/data/api.js
Normal file
26
src/components/bulk-email-tool/bulk-email-form/data/api.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
export async function postBulkEmailInstructorTask(email, courseId) {
|
||||
try {
|
||||
const url = `${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor/api/send_email`;
|
||||
const response = await getAuthenticatedHttpClient().post(url, email);
|
||||
return response;
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchScheduledBulkEmailInstructorTask(emailData, courseId, scheduleId) {
|
||||
const endpointUrl = `${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/${courseId}/bulk_email/${scheduleId}`;
|
||||
try {
|
||||
const response = await getAuthenticatedHttpClient().patch(endpointUrl, emailData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
130
src/components/bulk-email-tool/bulk-email-form/data/reducer.js
Normal file
130
src/components/bulk-email-tool/bulk-email-form/data/reducer.js
Normal file
@@ -0,0 +1,130 @@
|
||||
export function editorReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'EDITOR_ON_CHANGE':
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
case 'COPY_TO_EDITOR':
|
||||
return {
|
||||
...state,
|
||||
emailBody: action.payload.emailBody || '',
|
||||
emailSubject: action.payload.emailSubject || '',
|
||||
emailRecipients: action.payload.emailRecipients || [],
|
||||
scheduleDate: action.payload.scheduleDate || '',
|
||||
scheduleTime: action.payload.scheduleTime || '',
|
||||
schedulingId: action.payload.schedulingId || '',
|
||||
emailId: action.payload.emailId || null,
|
||||
editMode: action.payload.editMode || false,
|
||||
};
|
||||
case 'ADD_RECIPIENT':
|
||||
return {
|
||||
...state,
|
||||
emailRecipients: [...state.emailRecipients, action.payload],
|
||||
};
|
||||
case 'REMOVE_RECIPIENT':
|
||||
return {
|
||||
...state,
|
||||
emailRecipients: state.emailRecipients.filter((value) => value !== action.payload),
|
||||
};
|
||||
case 'CLEAR_RECIPIENTS':
|
||||
return {
|
||||
...state,
|
||||
emailRecipients: [],
|
||||
};
|
||||
case 'CLEAR_DATE_TIME':
|
||||
return {
|
||||
...state,
|
||||
scheduleDate: '',
|
||||
scheduleTime: '',
|
||||
editMode: false,
|
||||
};
|
||||
case 'CLEAR_EDITOR':
|
||||
return {
|
||||
...state,
|
||||
emailBody: '',
|
||||
emailSubject: '',
|
||||
scheduleDate: '',
|
||||
scheduleTime: '',
|
||||
emailRecipients: [],
|
||||
editMode: false,
|
||||
schedulingId: '',
|
||||
emailId: null,
|
||||
errorRetrievingData: false,
|
||||
formComplete: false,
|
||||
};
|
||||
case 'CLEAR_ERROR':
|
||||
return {
|
||||
...state,
|
||||
errorRetrievingData: false,
|
||||
formComplete: false,
|
||||
};
|
||||
case 'SET_EDIT_MODE':
|
||||
return {
|
||||
...state,
|
||||
editMode: action.payload,
|
||||
};
|
||||
case 'PATCH_SCHEDULED_EMAIL':
|
||||
return state;
|
||||
case 'PATCH_START':
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
};
|
||||
case 'PATCH_COMPLETE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
errorRetrievingData: false,
|
||||
formComplete: true,
|
||||
...action.payload,
|
||||
};
|
||||
case 'PATCH_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
errorRetrievingData: true,
|
||||
formComplete: false,
|
||||
};
|
||||
case 'POST_BULK_EMAIL':
|
||||
return state;
|
||||
case 'POST_START':
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
};
|
||||
case 'POST_COMPLETE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
errorRetrievingData: false,
|
||||
formComplete: true,
|
||||
...action.payload,
|
||||
};
|
||||
case 'POST_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
errorRetrievingData: true,
|
||||
formComplete: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export const editorInitialState = {
|
||||
emailBody: '',
|
||||
emailSubject: '',
|
||||
scheduleDate: '',
|
||||
scheduleTime: '',
|
||||
emailRecipients: [],
|
||||
editMode: false,
|
||||
schedulingId: '',
|
||||
emailId: null,
|
||||
isLoading: false,
|
||||
errorRetrievingData: false,
|
||||
formComplete: false,
|
||||
};
|
||||
|
||||
export default editorReducer;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { initializeMockApp } from '../../../../../setupTest';
|
||||
import { editorInitialState, editorReducer } from '../reducer';
|
||||
|
||||
describe('editorReducer', () => {
|
||||
const testState = editorInitialState;
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
|
||||
it('does not remove present data from slice when EDITOR_ON_CHANGE action dispatched', () => {
|
||||
const newEditorState = {
|
||||
emailBody: 'test',
|
||||
};
|
||||
const returnedState = editorReducer(testState, { type: 'EDITOR_ON_CHANGE', payload: newEditorState });
|
||||
const finalState = {
|
||||
...testState,
|
||||
emailBody: 'test',
|
||||
};
|
||||
expect(returnedState).toEqual(finalState);
|
||||
});
|
||||
|
||||
it('it copies full editor state when COPY_TO_EDITOR action dispatched', () => {
|
||||
const newEditorState = {
|
||||
emailBody: 'test',
|
||||
emailSubject: 'test',
|
||||
emailRecipients: ['test'],
|
||||
};
|
||||
const finalState = {
|
||||
...testState,
|
||||
...newEditorState,
|
||||
};
|
||||
const returnedState = editorReducer(testState, { type: 'COPY_TO_EDITOR', payload: newEditorState });
|
||||
expect(returnedState).toEqual(finalState);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
patchScheduledEmail,
|
||||
patchScheduledEmailComplete,
|
||||
patchScheduledEmailError,
|
||||
patchScheduledEmailStart,
|
||||
postBulkEmail,
|
||||
postBulkEmailComplete,
|
||||
postBulkEmailError,
|
||||
postBulkEmailStart,
|
||||
} from './actions';
|
||||
import { patchScheduledBulkEmailInstructorTask, postBulkEmailInstructorTask } from './api';
|
||||
|
||||
export function postBulkEmailThunk(emailData, courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(postBulkEmail());
|
||||
dispatch(postBulkEmailStart());
|
||||
function onComplete(data) {
|
||||
dispatch(postBulkEmailComplete());
|
||||
return data;
|
||||
}
|
||||
function onError(error) {
|
||||
dispatch(postBulkEmailError());
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
const data = await postBulkEmailInstructorTask(emailData, courseId);
|
||||
return onComplete(data);
|
||||
} catch (error) {
|
||||
return onError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function editScheduledEmailThunk(emailData, courseId, schedulingId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(patchScheduledEmail());
|
||||
dispatch(patchScheduledEmailStart());
|
||||
function onComplete(data) {
|
||||
dispatch(patchScheduledEmailComplete());
|
||||
return data;
|
||||
}
|
||||
function onError(error) {
|
||||
dispatch(patchScheduledEmailError());
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
const data = await patchScheduledBulkEmailInstructorTask(emailData, courseId, schedulingId);
|
||||
return onComplete(data);
|
||||
} catch (error) {
|
||||
return onError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export { default } from './BulkEmailForm';
|
||||
|
||||
124
src/components/bulk-email-tool/bulk-email-form/messages.js
Normal file
124
src/components/bulk-email-tool/bulk-email-form/messages.js
Normal file
@@ -0,0 +1,124 @@
|
||||
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.',
|
||||
},
|
||||
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',
|
||||
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.new.email',
|
||||
defaultMessage:
|
||||
' When you select Send Email, you are creating a new email message that 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.',
|
||||
},
|
||||
bulkEmailFormSuccess: {
|
||||
id: 'bilk.email.form.success',
|
||||
defaultMessage: 'Email successfully created',
|
||||
},
|
||||
bulkEmailFormScheduledSuccess: {
|
||||
id: 'bulk.email.form.scheduled.success',
|
||||
defaultMessage: 'Email successfully scheduled',
|
||||
},
|
||||
bulkEmailSubmitButtonReschedule: {
|
||||
id: 'bulk.email.submit.button.reschedule',
|
||||
defaultMessage: 'Reschedule Email',
|
||||
},
|
||||
bulkEmailTaskAlertEditingDate: {
|
||||
id: 'bulk.email.task.alert.editing',
|
||||
defaultMessage: 'You are editing a scheduled email to be sent on: {dateTime}',
|
||||
description: 'This alert pops up before submitting when editing an email that has already been scheduled',
|
||||
},
|
||||
bulkEmailTaskAlertEditingSubject: {
|
||||
id: 'bulk.email.task.alert.subject',
|
||||
defaultMessage: 'with the subject: {subject}',
|
||||
},
|
||||
bulkEmailTaskAlertEditingTo: {
|
||||
id: 'bulk.email.task.alert.to',
|
||||
defaultMessage: 'to recipients:',
|
||||
},
|
||||
bulkEmailTaskAlertEditingWarning: {
|
||||
id: 'bulk.email.task.alert.warning',
|
||||
defaultMessage: 'This will not create a new scheduled email task and instead overwrite the one currently selected. Do you want to overwrite this scheduled email?',
|
||||
description: 'This alert pops up before submitting when editing an email that has already been scheduled',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -2,64 +2,178 @@
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import {
|
||||
render, screen, cleanup, act, fireEvent,
|
||||
render, screen, cleanup, fireEvent, initializeMockApp, getConfig,
|
||||
} from '../../../../setupTest';
|
||||
import BulkEmailForm from '..';
|
||||
import { postBulkEmail } from '../api';
|
||||
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');
|
||||
jest.mock('../api', () => ({
|
||||
__esModule: true,
|
||||
postBulkEmail: jest.fn(() => ({ status: 200 })),
|
||||
}));
|
||||
|
||||
const appendMock = jest.spyOn(FormData.prototype, 'append');
|
||||
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}
|
||||
/>
|
||||
</BulkEmailProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBulkEmailFormContext(value) {
|
||||
return (
|
||||
<BulkEmailContext.Provider value={[value, dispatchMock]}>
|
||||
<BulkEmailForm courseId="test" courseMode={courseMode} />
|
||||
</BulkEmailContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('bulk-email-form', () => {
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
beforeEach(() => jest.resetModules());
|
||||
afterEach(cleanup);
|
||||
afterEach(() => cleanup());
|
||||
test('it renders', () => {
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
expect(screen.getByText('Submit')).toBeTruthy();
|
||||
render(renderBulkEmailForm());
|
||||
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'));
|
||||
render(renderBulkEmailForm());
|
||||
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'));
|
||||
render(renderBulkEmailForm());
|
||||
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();
|
||||
expect(await screen.findByText('A subject is required')).toBeInTheDocument();
|
||||
});
|
||||
test('Shows complete message on completed POST', async () => {
|
||||
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onPost().reply(200, {
|
||||
course_id: 'test',
|
||||
success: true,
|
||||
});
|
||||
render(renderBulkEmailForm());
|
||||
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.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'test subject' } });
|
||||
fireEvent.change(screen.getByTestId('textEditor'), { target: { value: 'test body' } });
|
||||
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();
|
||||
});
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/courses/test/instructor/api/send_email`).reply(500);
|
||||
render(renderBulkEmailForm());
|
||||
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.change(screen.getByTestId('textEditor'), { target: { value: 'test body' } });
|
||||
fireEvent.click(screen.getByText('Send email'));
|
||||
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /continue/i }));
|
||||
expect(await screen.findByText('An error occured while attempting to send the email.')).toBeInTheDocument();
|
||||
});
|
||||
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 { cohorts } = cohortFactory.build();
|
||||
cohorts.forEach(cohort => expect(screen.getByRole('checkbox', { name: `Cohort: ${cohort}` })).toBeDisabled());
|
||||
expect(verifiedLearners).toBeDisabled();
|
||||
expect(auditLearners).toBeDisabled();
|
||||
});
|
||||
test('Shows scheduling form when checkbox is checked and submit is changed', async () => {
|
||||
render(renderBulkEmailForm());
|
||||
const scheduleCheckbox = screen.getByText('Schedule this email for a future date');
|
||||
fireEvent.click(scheduleCheckbox);
|
||||
expect(screen.getByText('Send time'));
|
||||
expect(screen.getByText('Send date'));
|
||||
expect(screen.getByText('Schedule Email'));
|
||||
});
|
||||
test('Prevents sending email when scheduling inputs are empty', async () => {
|
||||
render(renderBulkEmailForm());
|
||||
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, and must be a date in the future'));
|
||||
});
|
||||
test('Adds scheduling data to POST requests when schedule is selected', async () => {
|
||||
const postBulkEmailInstructorTask = jest.spyOn(bulkEmailFormApi, 'postBulkEmailInstructorTask');
|
||||
render(renderBulkEmailForm());
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Myself' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'test subject' } });
|
||||
fireEvent.change(screen.getByTestId('textEditor'), { target: { value: 'test body' } });
|
||||
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: formatDate(tomorrow) } });
|
||||
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(formatDate(tomorrow)));
|
||||
expect(postBulkEmailInstructorTask).toHaveBeenCalledWith(expect.any(FormData), expect.stringContaining('test'));
|
||||
});
|
||||
test('will PATCH instead of POST when in edit mode', async () => {
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onPatch().reply(200);
|
||||
render(
|
||||
renderBulkEmailFormContext({
|
||||
editor: {
|
||||
editMode: true,
|
||||
emailBody: 'test',
|
||||
emailSubject: 'test',
|
||||
emailRecipients: ['test'],
|
||||
scheduleDate: formatDate(tomorrow),
|
||||
scheduleTime: '10:00',
|
||||
schedulingId: 1,
|
||||
emailId: 1,
|
||||
isLoading: false,
|
||||
errorRetrievingData: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const submitButton = screen.getByText('Reschedule Email');
|
||||
fireEvent.click(submitButton);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'This will not create a new scheduled email task and instead overwrite the one currently selected. Do you want to overwrite this scheduled email?',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const continueButton = await screen.findByRole('button', { name: /continue/i });
|
||||
fireEvent.click(continueButton);
|
||||
expect(dispatchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
/* eslint-disable react/no-unstable-nested-components */
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import {
|
||||
Button, Icon, Modal, StatefulButton,
|
||||
} from '@edx/paragon';
|
||||
import { SpinnerSimple } from '@edx/paragon/icons';
|
||||
Button, Collapsible, Icon,
|
||||
} from '@openedx/paragon';
|
||||
import { SpinnerSimple } from '@openedx/paragon/icons';
|
||||
import messages from './messages';
|
||||
import { getSentEmailHistory } from './data/api';
|
||||
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
|
||||
import ViewEmailModal from './ViewEmailModal';
|
||||
|
||||
export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
|
||||
function BulkEmailContentHistory({ intl }) {
|
||||
const { courseId } = useParams();
|
||||
const BUTTON_STATE = {
|
||||
DEFAULT: 'default',
|
||||
PENDING: 'pending',
|
||||
COMPLETE: 'complete',
|
||||
};
|
||||
const [emailHistoryData, setEmailHistoryData] = useState();
|
||||
const [errorRetrievingData, setErrorRetrievingData] = useState(false);
|
||||
const [showHistoricalEmailContentTable, setShowHistoricalEmailContentTable] = useState(false);
|
||||
const [isMessageModalOpen, setIsMessageModalOpen] = useState(false);
|
||||
const [messageContent, setMessageContent] = useState();
|
||||
const [buttonState, setButtonState] = useState(BUTTON_STATE.DEFAULT);
|
||||
|
||||
/**
|
||||
* Async function that makes a REST API call to retrieve historical email message data sent by the bulk course email
|
||||
@@ -32,7 +29,6 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
|
||||
async function fetchSentEmailHistoryData() {
|
||||
setErrorRetrievingData(false);
|
||||
setShowHistoricalEmailContentTable(false);
|
||||
setButtonState(BUTTON_STATE.PENDING);
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
@@ -47,7 +43,6 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
|
||||
}
|
||||
|
||||
setShowHistoricalEmailContentTable(true);
|
||||
setButtonState(BUTTON_STATE.COMPLETE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,17 +51,15 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
|
||||
* up a level (the `subject` field). We also convert the `sent_to` data to be a String rather than an array to fix a
|
||||
* display bug in the table.
|
||||
*/
|
||||
function transformDataForTable() {
|
||||
let tableData = {};
|
||||
if (emailHistoryData) {
|
||||
tableData = emailHistoryData.map((item) => ({
|
||||
...item,
|
||||
subject: item.email.subject,
|
||||
sent_to: item.sent_to.join(', '),
|
||||
}));
|
||||
}
|
||||
return tableData;
|
||||
}
|
||||
const transformDataForTable = useMemo(() => {
|
||||
const tableData = emailHistoryData?.map((item) => ({
|
||||
...item,
|
||||
subject: item.email.subject,
|
||||
sent_to: item.sent_to.join(', '),
|
||||
created: new Date(item.created).toLocaleString(),
|
||||
}));
|
||||
return tableData || [];
|
||||
}, [emailHistoryData]);
|
||||
|
||||
/**
|
||||
* This function is responsible for setting the current `messageContent` state data. This will be the contents of a
|
||||
@@ -78,58 +71,6 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
|
||||
setIsMessageModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a modal that will display the contents of a single historical email message sent via the bulk course email
|
||||
* tool to a user.
|
||||
*/
|
||||
const renderMessageModal = () => (
|
||||
<div>
|
||||
<Modal
|
||||
open={isMessageModalOpen}
|
||||
title=""
|
||||
body={(
|
||||
<div>
|
||||
<div className="d-flex flex-row">
|
||||
<p>{intl.formatMessage(messages.modalMessageSubject)}</p>
|
||||
<p className="pl-2">{messageContent.subject}</p>
|
||||
</div>
|
||||
<div className="d-flex flex-row">
|
||||
<p>{intl.formatMessage(messages.modalMessageSentBy)}</p>
|
||||
<p className="pl-2">{messageContent.requester}</p>
|
||||
</div>
|
||||
<div className="d-flex flex-row">
|
||||
<p>{intl.formatMessage(messages.modalMessageTimeSent)}</p>
|
||||
<p className="pl-2">{messageContent.created}</p>
|
||||
</div>
|
||||
<div className="d-flex flex-row">
|
||||
<p>{intl.formatMessage(messages.modalMessageSentTo)}</p>
|
||||
<p className="pl-2">{messageContent.sent_to}</p>
|
||||
</div>
|
||||
<hr className="py-2" />
|
||||
<div>
|
||||
<p>{intl.formatMessage(messages.modalMessageBody)}</p>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: messageContent.email.html_message }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onClose={() => setIsMessageModalOpen(false)}
|
||||
buttons={[
|
||||
<Button onClick={() => {
|
||||
copyTextToEditor(messageContent.email.html_message);
|
||||
setIsMessageModalOpen(false);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="bulk.email.tool.copy.message.button"
|
||||
defaultMessage="Copy to editor"
|
||||
/>
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
Header: `${intl.formatMessage(messages.emailHistoryTableColumnHeaderSubject)}`,
|
||||
@@ -159,7 +100,7 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
|
||||
* contents of a previously sent message.
|
||||
*/
|
||||
const additionalColumns = () => {
|
||||
const tableData = transformDataForTable();
|
||||
const tableData = transformDataForTable;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -168,6 +109,7 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
|
||||
Cell: ({ row }) => (
|
||||
<Button variant="link" className="px-1" onClick={() => onViewMessageClick(tableData[row.index])}>
|
||||
{intl.formatMessage(messages.buttonViewMessage)}
|
||||
<span className="sr-only"> {row.index}</span>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
@@ -176,40 +118,36 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{messageContent && renderMessageModal()}</div>
|
||||
{messageContent && (
|
||||
<ViewEmailModal
|
||||
messageContent={messageContent}
|
||||
isOpen={isMessageModalOpen}
|
||||
setModalOpen={setIsMessageModalOpen}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p>{intl.formatMessage(messages.emailHistoryTableSectionButtonHeader)}</p>
|
||||
<StatefulButton
|
||||
className="btn btn-outline-primary mb-2"
|
||||
variant="outline-primary"
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
await fetchSentEmailHistoryData();
|
||||
}}
|
||||
labels={{
|
||||
default: `${intl.formatMessage(messages.emailHistoryTableSectionButton)}`,
|
||||
pending: `${intl.formatMessage(messages.emailHistoryTableSectionButton)}`,
|
||||
complete: `${intl.formatMessage(messages.emailHistoryTableSectionButton)}`,
|
||||
}}
|
||||
icons={{
|
||||
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
|
||||
}}
|
||||
disabledStates={['error']}
|
||||
state={buttonState}
|
||||
<Collapsible
|
||||
styling="card"
|
||||
title={intl.formatMessage(messages.emailHistoryTableSectionButton)}
|
||||
className="mb-3"
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onOpen={fetchSentEmailHistoryData}
|
||||
>
|
||||
{intl.formatMessage(messages.emailHistoryTableSectionButton)}
|
||||
</StatefulButton>
|
||||
{showHistoricalEmailContentTable && (
|
||||
<BulkEmailTaskManagerTable
|
||||
errorRetrievingData={errorRetrievingData}
|
||||
tableData={transformDataForTable()}
|
||||
tableDescription={intl.formatMessage(messages.emailHistoryTableViewMessageInstructions)}
|
||||
alertWarningMessage={intl.formatMessage(messages.noEmailData)}
|
||||
alertErrorMessage={intl.formatMessage(messages.errorFetchingEmailHistoryData)}
|
||||
columns={tableColumns}
|
||||
additionalColumns={additionalColumns()}
|
||||
/>
|
||||
)}
|
||||
{showHistoricalEmailContentTable ? (
|
||||
<BulkEmailTaskManagerTable
|
||||
errorRetrievingData={errorRetrievingData}
|
||||
tableData={transformDataForTable}
|
||||
tableDescription={intl.formatMessage(messages.emailHistoryTableViewMessageInstructions)}
|
||||
alertWarningMessage={intl.formatMessage(messages.noEmailData)}
|
||||
alertErrorMessage={intl.formatMessage(messages.errorFetchingEmailHistoryData)}
|
||||
columns={tableColumns}
|
||||
additionalColumns={additionalColumns()}
|
||||
/>
|
||||
) : (
|
||||
<Icon src={SpinnerSimple} className="icon-spin mx-auto" />
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -220,7 +158,6 @@ BulkEmailContentHistory.propTypes = {
|
||||
row: PropTypes.shape({
|
||||
index: PropTypes.number,
|
||||
}),
|
||||
copyTextToEditor: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
BulkEmailContentHistory.defaultProps = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, DataTable } from '@edx/paragon';
|
||||
import { Alert, DataTable } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
@@ -71,12 +71,12 @@ export default function BulkEmailTaskManagerTable(props) {
|
||||
|
||||
BulkEmailTaskManagerTable.propTypes = {
|
||||
errorRetrievingData: PropTypes.bool.isRequired,
|
||||
tableData: PropTypes.arrayOf(PropTypes.object),
|
||||
tableData: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
tableDescription: PropTypes.string,
|
||||
alertWarningMessage: PropTypes.string.isRequired,
|
||||
alertErrorMessage: PropTypes.string.isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
additionalColumns: PropTypes.arrayOf(PropTypes.object),
|
||||
columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
additionalColumns: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
};
|
||||
|
||||
BulkEmailTaskManagerTable.defaultProps = {
|
||||
|
||||
@@ -7,7 +7,7 @@ import messages from './messages';
|
||||
import useInterval from '../../../utils/useInterval';
|
||||
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
|
||||
|
||||
export function BulkEmailPendingTasks({ intl }) {
|
||||
function BulkEmailPendingTasks({ intl }) {
|
||||
const { courseId } = useParams();
|
||||
|
||||
const [instructorTaskData, setInstructorTaskData] = useState();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Hyperlink, Alert } from '@openedx/paragon';
|
||||
import { WarningFilled } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
export default function BulkEmailPendingTasksAlert(props) {
|
||||
const { courseId } = props;
|
||||
|
||||
return (
|
||||
<Alert variant="warning" icon={WarningFilled}>
|
||||
<FormattedMessage
|
||||
id="bulk.email.pending.tasks.description.one"
|
||||
defaultMessage="To view all pending tasks, including email, visit "
|
||||
/>
|
||||
<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=" in the Instructor Dashboard."
|
||||
/>
|
||||
</Alert>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
BulkEmailPendingTasksAlert.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,26 +1,22 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Icon, StatefulButton } from '@edx/paragon';
|
||||
import { SpinnerSimple } from '@edx/paragon/icons';
|
||||
import { Icon, Collapsible } from '@openedx/paragon';
|
||||
import { SpinnerSimple } from '@openedx/paragon/icons';
|
||||
import { getEmailTaskHistory } from './data/api';
|
||||
import messages from './messages';
|
||||
|
||||
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
|
||||
|
||||
export function BulkEmailTaskHistory({ intl }) {
|
||||
const { courseId } = useParams();
|
||||
const BUTTON_STATE = {
|
||||
DEFAULT: 'default',
|
||||
PENDING: 'pending',
|
||||
COMPLETE: 'complete',
|
||||
};
|
||||
import './bulkEmailTaskHistory.scss';
|
||||
|
||||
const [emailTaskHistoryData, setEmailTaskHistoryData] = useState();
|
||||
function BulkEmailTaskHistory({ intl }) {
|
||||
const { courseId } = useParams();
|
||||
|
||||
const [emailTaskHistoryData, setEmailTaskHistoryData] = useState([]);
|
||||
const [showHistoricalTaskContentTable, setShowHistoricalTaskContentTable] = useState(false);
|
||||
const [errorRetrievingData, setErrorRetrievingData] = useState(false);
|
||||
const [buttonState, setButtonState] = useState(BUTTON_STATE.DEFAULT);
|
||||
|
||||
/**
|
||||
* Async function that makes a REST API call to retrieve historical bulk email (Instructor) task data for display
|
||||
@@ -29,7 +25,6 @@ export function BulkEmailTaskHistory({ intl }) {
|
||||
async function fetchEmailTaskHistoryData() {
|
||||
setErrorRetrievingData(false);
|
||||
setShowHistoricalTaskContentTable(false);
|
||||
setButtonState(BUTTON_STATE.PENDING);
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
@@ -44,9 +39,16 @@ export function BulkEmailTaskHistory({ intl }) {
|
||||
}
|
||||
|
||||
setShowHistoricalTaskContentTable(true);
|
||||
setButtonState(BUTTON_STATE.COMPLETE);
|
||||
}
|
||||
|
||||
const transformDataForTable = useMemo(() => {
|
||||
const tableData = emailTaskHistoryData?.map((item) => ({
|
||||
...item,
|
||||
created: new Date(item.created).toLocaleString(),
|
||||
}));
|
||||
return tableData || [];
|
||||
}, [emailTaskHistoryData]);
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
Header: `${intl.formatMessage(messages.taskHistoryTableColumnHeaderTaskType)}`,
|
||||
@@ -87,38 +89,29 @@ export function BulkEmailTaskHistory({ intl }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="pb-4.5">
|
||||
<div>
|
||||
<p>
|
||||
{intl.formatMessage(messages.emailTaskHistoryTableSectionButtonHeader)}
|
||||
</p>
|
||||
<StatefulButton
|
||||
className="btn btn-outline-primary mb-2"
|
||||
variant="outline-primary"
|
||||
type="submit"
|
||||
onClick={async () => { await fetchEmailTaskHistoryData(); }}
|
||||
labels={{
|
||||
default: `${intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}`,
|
||||
pending: `${intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}`,
|
||||
complete: `${intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}`,
|
||||
}}
|
||||
icons={{
|
||||
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
|
||||
}}
|
||||
disabledStates={['error']}
|
||||
state={buttonState}
|
||||
<Collapsible
|
||||
styling="card"
|
||||
title={intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onOpen={fetchEmailTaskHistoryData}
|
||||
>
|
||||
{intl.formatMessage(messages.emailHistoryTableSectionButton)}
|
||||
</StatefulButton>
|
||||
{showHistoricalTaskContentTable && (
|
||||
<BulkEmailTaskManagerTable
|
||||
errorRetrievingData={errorRetrievingData}
|
||||
tableData={emailTaskHistoryData}
|
||||
alertWarningMessage={intl.formatMessage(messages.noTaskHistoryData)}
|
||||
alertErrorMessage={intl.formatMessage(messages.errorFetchingTaskHistoryData)}
|
||||
columns={tableColumns}
|
||||
/>
|
||||
)}
|
||||
{showHistoricalTaskContentTable ? (
|
||||
<BulkEmailTaskManagerTable
|
||||
errorRetrievingData={errorRetrievingData}
|
||||
tableData={transformDataForTable}
|
||||
alertWarningMessage={intl.formatMessage(messages.noTaskHistoryData)}
|
||||
alertErrorMessage={intl.formatMessage(messages.errorFetchingTaskHistoryData)}
|
||||
columns={tableColumns}
|
||||
/>
|
||||
) : (
|
||||
<Icon src={SpinnerSimple} className="icon-spin mx-auto" />
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import BulkEmailContentHistory from './BulkEmailContentHistory';
|
||||
import BulkEmailPendingTasks from './BulkEmailPendingTasks';
|
||||
import BulkEmailTaskHistory from './BulkEmailTaskHistory';
|
||||
import messages from './messages';
|
||||
import BulkEmailScheduledEmailsTable from './bulk-email-scheduled-emails-table';
|
||||
import BulkEmailPendingTasksAlert from './BulkEmailPendingTasksAlert';
|
||||
|
||||
export function BulkEmailTaskManager({ intl, copyTextToEditor }) {
|
||||
function BulkEmailTaskManager({ intl, courseId }) {
|
||||
return (
|
||||
<div className="px-5">
|
||||
<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">
|
||||
{intl.formatMessage(messages.pendingTasksHeader)}
|
||||
</h2>
|
||||
<BulkEmailPendingTasks />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="h3">
|
||||
{intl.formatMessage(messages.emailTaskHistoryHeader)}
|
||||
</h2>
|
||||
<BulkEmailContentHistory copyTextToEditor={copyTextToEditor} />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BulkEmailTaskManager.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
copyTextToEditor: PropTypes.func.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BulkEmailTaskManager);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, Button, ModalDialog } from '@openedx/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({
|
||||
intl, messageContent, isOpen, setModalOpen,
|
||||
}) {
|
||||
const [, dispatch] = useContext(BulkEmailContext);
|
||||
return (
|
||||
<div>
|
||||
<ModalDialog
|
||||
isOpen={isOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
hasCloseButton
|
||||
>
|
||||
<ModalDialog.Body>
|
||||
|
||||
<div>
|
||||
<div className="d-flex flex-row">
|
||||
<p>{intl.formatMessage(messages.modalMessageSubject)}</p>
|
||||
<p className="pl-2">{messageContent.subject}</p>
|
||||
</div>
|
||||
<div className="d-flex flex-row">
|
||||
<p>{intl.formatMessage(messages.modalMessageSentBy)}</p>
|
||||
<p className="pl-2">{messageContent.requester}</p>
|
||||
</div>
|
||||
<div className="d-flex flex-row">
|
||||
<p>{intl.formatMessage(messages.modalMessageTimeSent)}</p>
|
||||
<p className="pl-2">{messageContent.created}</p>
|
||||
</div>
|
||||
<div className="d-flex flex-row">
|
||||
<p>{intl.formatMessage(messages.modalMessageSentTo)}</p>
|
||||
<p className="pl-2">{messageContent.sent_to}</p>
|
||||
</div>
|
||||
<hr className="py-2" />
|
||||
<div>
|
||||
<p>{intl.formatMessage(messages.modalMessageBody)}</p>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ViewEmailModal.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
messageContent: PropTypes.shape({
|
||||
subject: PropTypes.string,
|
||||
requester: PropTypes.string,
|
||||
created: PropTypes.string,
|
||||
email: PropTypes.shape({
|
||||
html_message: PropTypes.string,
|
||||
}).isRequired,
|
||||
sent_to: PropTypes.string,
|
||||
}).isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
setModalOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ViewEmailModal);
|
||||
@@ -0,0 +1,203 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/no-unstable-nested-components */
|
||||
|
||||
import React, {
|
||||
useCallback, useContext, useState, useEffect,
|
||||
} from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert, DataTable, Icon, IconButton, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Delete, Info, Visibility, Edit,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { BulkEmailContext } from '../../bulk-email-context';
|
||||
import { deleteScheduledEmailThunk, getScheduledBulkEmailThunk } from './data/thunks';
|
||||
import messages from './messages';
|
||||
import ViewEmailModal from '../ViewEmailModal';
|
||||
import { copyToEditor } from '../../bulk-email-form/data/actions';
|
||||
import TaskAlertModal from '../../task-alert-modal';
|
||||
import { formatDate, formatTime } from '../../../../utils/formatDateAndTime';
|
||||
|
||||
function flattenScheduledEmailsArray(emails) {
|
||||
return emails.map((email) => ({
|
||||
schedulingId: email.id,
|
||||
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 { courseId } = useParams();
|
||||
const [{ scheduledEmailsTable }, dispatch] = useContext(BulkEmailContext);
|
||||
const [tableData, setTableData] = useState([]);
|
||||
const [viewModal, setViewModal] = useState({
|
||||
isOpen: false,
|
||||
messageContent: {},
|
||||
});
|
||||
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle();
|
||||
const [currentTask, setCurrentTask] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
setTableData(flattenScheduledEmailsArray(scheduledEmailsTable.results));
|
||||
}, [scheduledEmailsTable.results]);
|
||||
|
||||
const fetchTableData = useCallback((args) => {
|
||||
dispatch(getScheduledBulkEmailThunk(courseId, args.pageIndex + 1));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleViewEmail = (row) => {
|
||||
setViewModal({
|
||||
isOpen: true,
|
||||
messageContent: {
|
||||
subject: row.original.subject,
|
||||
requester: row.original.sender,
|
||||
created: row.original.taskDue,
|
||||
email: {
|
||||
html_message: row.original.htmlMessage,
|
||||
},
|
||||
sent_to: row.original.targets,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (scheduledEmailsTable.errorRetrievingData) {
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<Alert variant="danger" icon={Info}>
|
||||
<Alert.Heading>{intl.formatMessage(messages.bulkEmailScheduledEmailsTableErrorHeader)}</Alert.Heading>
|
||||
<p>{intl.formatMessage(messages.bulkEmailScheduledEmailsTableError)}</p>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteEmail = async () => {
|
||||
const {
|
||||
row, pageIndex, page, previousPage,
|
||||
} = currentTask;
|
||||
await dispatch(deleteScheduledEmailThunk(courseId, row.original.schedulingId));
|
||||
if (page.length === 1 && pageIndex !== 0) {
|
||||
previousPage();
|
||||
} else {
|
||||
dispatch(getScheduledBulkEmailThunk(courseId, pageIndex + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEmail = (row) => {
|
||||
const {
|
||||
original: {
|
||||
htmlMessage: emailBody, subject: emailSubject, taskDueUTC, targets, schedulingId, emailId,
|
||||
},
|
||||
} = row;
|
||||
const dateTime = new Date(taskDueUTC);
|
||||
const emailRecipients = targets.replaceAll('-', ':').split(', ');
|
||||
const scheduleDate = formatDate(dateTime);
|
||||
const scheduleTime = formatTime(dateTime);
|
||||
dispatch(
|
||||
copyToEditor({
|
||||
emailId,
|
||||
emailBody,
|
||||
emailSubject,
|
||||
emailRecipients,
|
||||
scheduleDate,
|
||||
scheduleTime,
|
||||
schedulingId,
|
||||
editMode: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<TaskAlertModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
close={(event) => {
|
||||
closeConfirmModal();
|
||||
if (event.target.name === 'continue') {
|
||||
handleDeleteEmail();
|
||||
}
|
||||
}}
|
||||
alertMessage={intl.formatMessage(
|
||||
messages.bulkEmailScheduledEmailsTableConfirmDelete,
|
||||
{ date: currentTask?.row?.original?.taskDue ?? '' },
|
||||
)}
|
||||
/>
|
||||
{viewModal.isOpen && (
|
||||
<ViewEmailModal
|
||||
isOpen={viewModal.isOpen}
|
||||
setModalOpen={(open) => setViewModal({ isOpen: open })}
|
||||
messageContent={viewModal.messageContent}
|
||||
/>
|
||||
)}
|
||||
<div className="pb-4">
|
||||
<DataTable
|
||||
isLoading={scheduledEmailsTable.isLoading}
|
||||
itemCount={scheduledEmailsTable.count}
|
||||
pageCount={scheduledEmailsTable.numPages}
|
||||
data={tableData}
|
||||
isPaginated
|
||||
manualPagination
|
||||
fetchData={fetchTableData}
|
||||
initialState={{
|
||||
pageSize: 10,
|
||||
pageIndex: 0,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
Header: intl.formatMessage(messages.bulkEmailScheduledEmailsTableSendDate),
|
||||
accessor: 'taskDue',
|
||||
},
|
||||
{
|
||||
Header: intl.formatMessage(messages.bulkEmailScheduledEmailsTableSendTo),
|
||||
accessor: 'targets',
|
||||
},
|
||||
{
|
||||
Header: intl.formatMessage(messages.bulkEmailScheduledEmailsTableSubject),
|
||||
accessor: 'subject',
|
||||
},
|
||||
{
|
||||
Header: intl.formatMessage(messages.bulkEmailScheduledEmailsTableAuthor),
|
||||
accessor: 'sender',
|
||||
},
|
||||
]}
|
||||
additionalColumns={[
|
||||
{
|
||||
id: 'action',
|
||||
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)} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BulkEmailScheduledEmailsTable.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BulkEmailScheduledEmailsTable);
|
||||
@@ -0,0 +1,31 @@
|
||||
export const fetchScheduledEmails = () => ({
|
||||
type: 'FETCH_SCHEDULED_EMAILS',
|
||||
});
|
||||
|
||||
export const fetchScheduledEmailsStart = () => ({
|
||||
type: 'FETCH_START',
|
||||
});
|
||||
|
||||
export const fetchScheduledEmailsComplete = (payload) => ({
|
||||
type: 'FETCH_COMPLETE',
|
||||
payload,
|
||||
});
|
||||
|
||||
export const fetchScheduledEmailsError = () => ({
|
||||
type: 'FETCH_FAILURE',
|
||||
});
|
||||
|
||||
export const deleteScheduledEmail = () => ({
|
||||
type: 'DELETE_SCHEDULED_EMAIL',
|
||||
});
|
||||
export const deleteScheduledEmailStart = () => ({
|
||||
type: 'DELETE_START',
|
||||
});
|
||||
|
||||
export const deleteScheduledEmailComplete = () => ({
|
||||
type: 'DELETE_COMPLETE',
|
||||
});
|
||||
|
||||
export const deleteScheduledEmailError = () => ({
|
||||
type: 'DELETE_FAILURE',
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function getScheduledBulkEmailIntructorTaskData(courseId, page = 1) {
|
||||
const endpointUrl = `${
|
||||
getConfig().LMS_BASE_URL
|
||||
}/api/instructor_task/v1/schedules/${courseId}/bulk_email/?page=${page}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(endpointUrl);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteScheduledBulkEmailInstructorTask(courseId, scheduleId) {
|
||||
const endpointUrl = `${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/${courseId}/bulk_email/${scheduleId}`;
|
||||
try {
|
||||
const { status } = await getAuthenticatedHttpClient().delete(endpointUrl);
|
||||
return status;
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export function scheduledEmailsTableReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'FETCH_SCHEDULED_EMAILS':
|
||||
return state;
|
||||
case 'FETCH_START':
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
};
|
||||
case 'FETCH_COMPLETE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
errorRetrievingData: false,
|
||||
...action.payload,
|
||||
};
|
||||
case 'FETCH_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
errorRetrievingData: true,
|
||||
};
|
||||
case 'DELETE_SCHEDULED_EMAIL':
|
||||
return state;
|
||||
case 'DELETE_START':
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
};
|
||||
case 'DELETE_COMPLETE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
errorRetrievingData: false,
|
||||
...action.payload,
|
||||
};
|
||||
case 'DELETE_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
errorRetrievingData: true,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export const scheduledEmailsTableInitialState = {
|
||||
results: [],
|
||||
isLoading: false,
|
||||
errorRetrievingData: false,
|
||||
count: 0,
|
||||
numPages: 0,
|
||||
currentPage: 0,
|
||||
start: 0,
|
||||
previous: null,
|
||||
next: null,
|
||||
};
|
||||
|
||||
export default scheduledEmailsTableReducer;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { initializeMockApp } from '../../../../../../setupTest';
|
||||
import { scheduledEmailsTableReducer } from '../reducer';
|
||||
|
||||
describe('scheduledEmailsTableReducer', () => {
|
||||
const testState = {
|
||||
scheduledEmails: [],
|
||||
isLoading: false,
|
||||
errorRetrievingData: false,
|
||||
};
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
|
||||
it('does not change state on FETCH_SCHEDULED_EMAILS', () => {
|
||||
expect(scheduledEmailsTableReducer(testState, { type: 'FETCH_SCHEDULED_EMAILS' })).toEqual(testState);
|
||||
});
|
||||
it('sets loading state on FETCH_START', () => {
|
||||
const finalState = {
|
||||
...testState,
|
||||
isLoading: true,
|
||||
};
|
||||
const returnedState = scheduledEmailsTableReducer(testState, { type: 'FETCH_START' });
|
||||
expect(returnedState).toEqual(finalState);
|
||||
});
|
||||
it('adds payload on FETCH_COMPLETE', () => {
|
||||
const finalState = {
|
||||
...testState,
|
||||
additionalField: true,
|
||||
isLoading: false,
|
||||
};
|
||||
const returnedState = scheduledEmailsTableReducer(testState, { type: 'FETCH_COMPLETE', payload: { additionalField: true } });
|
||||
expect(returnedState).toEqual(finalState);
|
||||
expect(returnedState.isLoading).toEqual(false);
|
||||
expect(returnedState.errorRetrievingData).toEqual(false);
|
||||
});
|
||||
it('sets Error to true when FETCH_FAILURE action dispatched', () => {
|
||||
const finalState = {
|
||||
...testState,
|
||||
isLoading: false,
|
||||
errorRetrievingData: true,
|
||||
};
|
||||
const returnedState = scheduledEmailsTableReducer(testState, { type: 'FETCH_FAILURE' });
|
||||
expect(returnedState).toEqual(finalState);
|
||||
});
|
||||
it('properly sets state on DELETE_COMPLETE', () => {
|
||||
const finalState = {
|
||||
...testState,
|
||||
};
|
||||
const returnedState = scheduledEmailsTableReducer(testState, { type: 'DELETE_COMPLETE' });
|
||||
expect(returnedState).toEqual(finalState);
|
||||
expect(returnedState.isLoading).toEqual(false);
|
||||
expect(returnedState.errorRetrievingData).toEqual(false);
|
||||
});
|
||||
it('sets Error when DELETE_FAILURE action dispatched', () => {
|
||||
const finalState = {
|
||||
...testState,
|
||||
isLoading: false,
|
||||
errorRetrievingData: true,
|
||||
};
|
||||
const returnedState = scheduledEmailsTableReducer(testState, { type: 'DELETE_FAILURE' });
|
||||
expect(returnedState).toEqual(finalState);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
deleteScheduledEmail,
|
||||
deleteScheduledEmailComplete,
|
||||
deleteScheduledEmailError,
|
||||
deleteScheduledEmailStart,
|
||||
fetchScheduledEmails,
|
||||
fetchScheduledEmailsComplete,
|
||||
fetchScheduledEmailsError,
|
||||
fetchScheduledEmailsStart,
|
||||
} from './actions';
|
||||
import { deleteScheduledBulkEmailInstructorTask, getScheduledBulkEmailIntructorTaskData } from './api';
|
||||
|
||||
export function getScheduledBulkEmailThunk(courseId, page) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchScheduledEmails());
|
||||
dispatch(fetchScheduledEmailsStart());
|
||||
function onComplete(data) {
|
||||
dispatch(fetchScheduledEmailsComplete(data));
|
||||
return data;
|
||||
}
|
||||
function onError(error) {
|
||||
dispatch(fetchScheduledEmailsError());
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
const data = await getScheduledBulkEmailIntructorTaskData(courseId, page);
|
||||
return onComplete(data);
|
||||
} catch (error) {
|
||||
return onError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteScheduledEmailThunk(courseId, emailIndex) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteScheduledEmail());
|
||||
dispatch(deleteScheduledEmailStart());
|
||||
function onComplete(data) {
|
||||
dispatch(deleteScheduledEmailComplete(data));
|
||||
return data;
|
||||
}
|
||||
function onError(error) {
|
||||
dispatch(deleteScheduledEmailError());
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
const status = await deleteScheduledBulkEmailInstructorTask(courseId, emailIndex);
|
||||
return onComplete(status);
|
||||
} catch (error) {
|
||||
return onError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export { default } from './BulkEmailScheduledEmailsTable';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
/* BulkEmailScheduledEmailsTable.jsx Messages */
|
||||
bulkEmailScheduledEmailsTableErrorHeader: {
|
||||
id: 'bulk.email.scheduled.emails.table.error.header',
|
||||
defaultMessage: 'Error',
|
||||
},
|
||||
|
||||
bulkEmailScheduledEmailsTableError: {
|
||||
id: 'bulk.email.scheduled.emails.table.error',
|
||||
defaultMessage: 'An error occured while retrieving scheduled email information. Please try again later.',
|
||||
description: 'An error message that shows if the app is unable to display scheduled emails in the table',
|
||||
},
|
||||
bulkEmailScheduledEmailsTableSendDate: {
|
||||
id: 'bulk.email.scheduled.emails.table.sendDate',
|
||||
defaultMessage: 'Send date',
|
||||
},
|
||||
bulkEmailScheduledEmailsTableSendTo: {
|
||||
id: 'bulk.email.scheduled.emails.table.sendTo',
|
||||
defaultMessage: 'Send to',
|
||||
},
|
||||
bulkEmailScheduledEmailsTableSubject: {
|
||||
id: 'bulk.email.scheduled.emails.table.subject',
|
||||
defaultMessage: 'Subject',
|
||||
},
|
||||
bulkEmailScheduledEmailsTableAuthor: {
|
||||
id: 'bulk.email.scheduled.emails.table.Author',
|
||||
defaultMessage: 'Author',
|
||||
},
|
||||
bulkEmailScheduledEmailsTableConfirmDelete: {
|
||||
id: 'bulk.email.scheduled.emails.table.confirm.delete',
|
||||
defaultMessage: 'You are deleting an email scheduled to be sent on {date}. The email will not be sent and the scheduling canceled. Are you sure?',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
render, screen, cleanup, fireEvent, initializeMockApp,
|
||||
} from '../../../../../setupTest';
|
||||
import { BulkEmailProvider } from '../../../bulk-email-context';
|
||||
import BulkEmailScheduledEmailsTable from '..';
|
||||
import scheduledEmailsFactory from './__factories__/scheduledEmails.factory';
|
||||
import * as actions from '../../../bulk-email-form/data/actions';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: jest.fn().mockReturnValue({ courseId: 'test-id' }),
|
||||
}));
|
||||
|
||||
function renderBulkEmailScheduledEmailsTable() {
|
||||
return (
|
||||
<BulkEmailProvider>
|
||||
<BulkEmailScheduledEmailsTable />
|
||||
</BulkEmailProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('BulkEmailScheduledEmailsTable', () => {
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Factory.resetAll();
|
||||
});
|
||||
|
||||
it('properly renders scheduled emails', async () => {
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
|
||||
.reply(200, scheduledEmailsFactory.build(1));
|
||||
render(renderBulkEmailScheduledEmailsTable());
|
||||
expect(await screen.findByText('learners')).toBeTruthy();
|
||||
expect(await screen.findByText('subject')).toBeTruthy();
|
||||
expect(await screen.findByText('edx')).toBeTruthy();
|
||||
expect(await screen.findByLabelText('View')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an error when the fetch fails', async () => {
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
|
||||
.reply(500, { response: 500 });
|
||||
render(renderBulkEmailScheduledEmailsTable());
|
||||
expect(
|
||||
await screen.findByText('An error occured while retrieving scheduled email information. Please try again later.'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('pops up the modal when viewing an email', async () => {
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
|
||||
.reply(200, scheduledEmailsFactory.build(1));
|
||||
render(renderBulkEmailScheduledEmailsTable());
|
||||
fireEvent.click(await screen.findByLabelText('View'));
|
||||
expect(await screen.findByText('Subject:')).toBeTruthy();
|
||||
expect(await screen.findByText('Sent by:')).toBeTruthy();
|
||||
expect(await screen.findByText('Time sent:')).toBeTruthy();
|
||||
expect(await screen.findByText('Sent to:')).toBeTruthy();
|
||||
expect(await screen.findByText('Message:')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('properly formats data for editing mode', async () => {
|
||||
const editorObj = {
|
||||
editMode: true,
|
||||
emailId: 1,
|
||||
emailBody: '<p>body</p>',
|
||||
emailSubject: 'subject',
|
||||
emailRecipients: ['learners'],
|
||||
scheduleDate: '2022-04-27',
|
||||
scheduleTime: '00:00',
|
||||
schedulingId: 1,
|
||||
};
|
||||
jest.spyOn(actions, 'copyToEditor');
|
||||
jest.spyOn(actions, 'setEditMode');
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
|
||||
.reply(200, scheduledEmailsFactory.build(1));
|
||||
render(renderBulkEmailScheduledEmailsTable());
|
||||
fireEvent.click(await screen.findByLabelText('Edit'));
|
||||
expect(actions.copyToEditor).toHaveBeenCalledWith(editorObj);
|
||||
});
|
||||
it('pops up alert on delete pressed', async () => {
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
|
||||
.replyOnce(200, scheduledEmailsFactory.build(1))
|
||||
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
|
||||
.replyOnce(200, {
|
||||
next: null,
|
||||
previous: null,
|
||||
count: 0,
|
||||
num_pages: 1,
|
||||
current_page: 1,
|
||||
start: 0,
|
||||
results: [],
|
||||
})
|
||||
.onDelete(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/1`)
|
||||
.reply(204, []);
|
||||
render(renderBulkEmailScheduledEmailsTable());
|
||||
fireEvent.click(await screen.findByLabelText('Delete'));
|
||||
expect(await screen.findByText('Caution')).toBeInTheDocument();
|
||||
});
|
||||
it('Deletes an email when clicking continue on warning', async () => {
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
|
||||
.replyOnce(200, scheduledEmailsFactory.build(1))
|
||||
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
|
||||
.replyOnce(200, {
|
||||
next: null,
|
||||
previous: null,
|
||||
count: 0,
|
||||
num_pages: 1,
|
||||
current_page: 1,
|
||||
start: 0,
|
||||
results: [],
|
||||
})
|
||||
.onDelete(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/1`)
|
||||
.reply(204, []);
|
||||
render(renderBulkEmailScheduledEmailsTable());
|
||||
fireEvent.click(await screen.findByLabelText('Delete'));
|
||||
expect(await screen.findByText('Caution')).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByText('Continue'));
|
||||
expect(await screen.findByText('No results found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('emailDataFactory')
|
||||
.sequence('id')
|
||||
.attrs({
|
||||
subject: 'subject',
|
||||
html_message: '<p>body</p>',
|
||||
text_message: 'body',
|
||||
course_id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
to_option: '',
|
||||
sender: 'edx',
|
||||
targets: ['learners'],
|
||||
});
|
||||
|
||||
export default Factory.define('courseEmailFactory')
|
||||
.sequence('id')
|
||||
.attr('course_email', Factory.build('emailDataFactory'))
|
||||
.sequence('task')
|
||||
.attr('task_due', '2022-04-27T00:00:00Z');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
import './courseEmail.factory';
|
||||
|
||||
export default Factory.define('scheduledEmailFactory')
|
||||
.attr(
|
||||
'next',
|
||||
'http://localhost:18000/api/instructor_task/v1/schedules/course-v1:edX+DemoX+Demo_Course/bulk_email/?page=$2',
|
||||
)
|
||||
.attr('previous', 'null')
|
||||
.option('count', '1')
|
||||
.attr('current_page', 1)
|
||||
.attr('start', 0)
|
||||
.attr('results', ['count'], (count) => {
|
||||
const emails = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
emails.push(Factory.build('courseEmailFactory'));
|
||||
}
|
||||
return emails;
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// Apply side scroll for table that overflows
|
||||
div.collapsible-body {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -34,6 +34,10 @@ 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.',
|
||||
@@ -60,7 +64,7 @@ const messages = defineMessages({
|
||||
},
|
||||
emailHistoryTableSectionButtonHeader: {
|
||||
id: 'bulk.email.content.history.table.button.header',
|
||||
defaultMessage: 'To see the content of previously sent emails, click this button:',
|
||||
defaultMessage: 'View the content of previously sent emails',
|
||||
},
|
||||
emailHistoryTableSectionButton: {
|
||||
id: 'bulk.email.content.history.table.button',
|
||||
@@ -69,7 +73,7 @@ const messages = defineMessages({
|
||||
/* BulkEmailTaskManager.jsx messages */
|
||||
pendingTasksHeader: {
|
||||
id: 'bulk.email.pending.tasks.header',
|
||||
defaultMessage: 'Pending Tasks',
|
||||
defaultMessage: 'Pending tasks has moved',
|
||||
},
|
||||
emailTaskHistoryHeader: {
|
||||
id: 'bulk.email.email.task.history.header',
|
||||
@@ -91,7 +95,7 @@ const messages = defineMessages({
|
||||
/* BulkEmailTaskHistory.jsx messages */
|
||||
emailTaskHistoryTableSectionButtonHeader: {
|
||||
id: 'bulk.email.task.history.table.button.header',
|
||||
defaultMessage: 'To see the status for all email tasks submitted for this course, click this button:',
|
||||
defaultMessage: 'View the status for all email tasks created for this course',
|
||||
},
|
||||
emailTaskHistoryTableSectionButton: {
|
||||
id: 'bulk.email.task.history.table.button',
|
||||
@@ -142,6 +146,10 @@ const messages = defineMessages({
|
||||
id: 'bulk.email.task.history.table.column.header.taskProgress',
|
||||
defaultMessage: 'Task Progress',
|
||||
},
|
||||
scheduledEmailsTableHeader: {
|
||||
id: 'bulk.email.scheduled.emails.table.header',
|
||||
defaultMessage: 'Scheduled emails',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
render, screen, fireEvent, cleanup, act,
|
||||
render, screen, fireEvent, cleanup, act, initializeMockApp,
|
||||
} from '../../../../setupTest';
|
||||
import { BulkEmailProvider } from '../../bulk-email-context';
|
||||
import BulkEmailContentHistory from '../BulkEmailContentHistory';
|
||||
import { getSentEmailHistory } from '../data/api';
|
||||
import buildEmailContentHistoryData from '../data/__factories__/emailContentHistory.factory';
|
||||
@@ -14,14 +15,25 @@ jest.mock('../data/api', () => ({
|
||||
getSentEmailHistory: jest.fn(() => {}),
|
||||
}));
|
||||
|
||||
function renderBulkEmailContentHistory() {
|
||||
return (
|
||||
<BulkEmailProvider>
|
||||
<BulkEmailContentHistory courseId="test-course-id" />
|
||||
</BulkEmailProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('BulkEmailContentHistory component', () => {
|
||||
beforeEach(() => jest.resetModules());
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders correctly', async () => {
|
||||
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
|
||||
render(renderBulkEmailContentHistory());
|
||||
const tableDescription = await screen.findByText(
|
||||
'To see the content of previously sent emails, click this button:',
|
||||
'View the content of previously sent emails',
|
||||
);
|
||||
expect(tableDescription).toBeTruthy();
|
||||
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
|
||||
@@ -33,7 +45,7 @@ describe('BulkEmailContentHistory component', () => {
|
||||
const emailHistoryData = buildEmailContentHistoryData(1);
|
||||
getSentEmailHistory.mockImplementation(() => emailHistoryData);
|
||||
|
||||
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
|
||||
render(renderBulkEmailContentHistory());
|
||||
|
||||
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
|
||||
fireEvent.click(showEmailContentHistoryButton);
|
||||
@@ -54,11 +66,14 @@ describe('BulkEmailContentHistory component', () => {
|
||||
// verify table contents
|
||||
const { emails } = emailHistoryData;
|
||||
const email = emails[0];
|
||||
expect(await screen.findByText(email.created)).toBeTruthy();
|
||||
const createdDate = new Date(email.created).toLocaleString();
|
||||
expect(await screen.findByText(createdDate)).toBeTruthy();
|
||||
expect(await screen.findByText(email.number_sent)).toBeTruthy();
|
||||
expect(await screen.findByText(email.requester)).toBeTruthy();
|
||||
expect(await screen.findByText(email.sent_to.join(', '))).toBeTruthy();
|
||||
expect(await screen.findByText(email.email.subject)).toBeTruthy();
|
||||
// verify screen reader only <span />
|
||||
expect(await screen.findByText('0')).toHaveClass('sr-only');
|
||||
expect(await screen.findAllByText('View Message')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -68,7 +83,7 @@ describe('BulkEmailContentHistory component', () => {
|
||||
const emailHistoryData = buildEmailContentHistoryData(1);
|
||||
getSentEmailHistory.mockImplementation(() => emailHistoryData);
|
||||
|
||||
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
|
||||
render(renderBulkEmailContentHistory());
|
||||
|
||||
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
|
||||
fireEvent.click(showEmailContentHistoryButton);
|
||||
@@ -89,7 +104,8 @@ describe('BulkEmailContentHistory component', () => {
|
||||
expect(await screen.findByText('Message:')).toBeTruthy();
|
||||
expect(await screen.findAllByText(email.email.subject)).toBeTruthy();
|
||||
expect(await screen.findAllByText(email.requester)).toBeTruthy();
|
||||
expect(await screen.findAllByText(email.created)).toBeTruthy();
|
||||
const createdDate = new Date(email.created).toLocaleString();
|
||||
expect(await screen.findAllByText(createdDate)).toBeTruthy();
|
||||
expect(await screen.findAllByText(email.sent_to.join(', '))).toBeTruthy();
|
||||
// .replace() call strips the HTML tags from the string
|
||||
expect(await screen.findByText(email.email.html_message.replace(/<[^>]*>?/gm, ''))).toBeTruthy();
|
||||
@@ -101,7 +117,7 @@ describe('BulkEmailContentHistory component', () => {
|
||||
const emailHistoryData = buildEmailContentHistoryData(0);
|
||||
getSentEmailHistory.mockImplementation(() => emailHistoryData);
|
||||
// render the component
|
||||
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
|
||||
render(renderBulkEmailContentHistory());
|
||||
// press the `show sent email history` button to initiate data retrieval
|
||||
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
|
||||
fireEvent.click(showEmailContentHistoryButton);
|
||||
@@ -117,7 +133,7 @@ describe('BulkEmailContentHistory component', () => {
|
||||
throw new Error();
|
||||
});
|
||||
// render the component
|
||||
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
|
||||
render(renderBulkEmailContentHistory());
|
||||
// press the `show sent email history` button to initiate data retrieval
|
||||
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
|
||||
fireEvent.click(showEmailContentHistoryButton);
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -27,68 +30,72 @@ describe('BulkEmailPendingTasks component', () => {
|
||||
});
|
||||
|
||||
test('renders a table when running Instructor Task data is returned', async () => {
|
||||
await act(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers();
|
||||
|
||||
const pendingInstructorTaskData = buildPendingInstructorTaskData(1);
|
||||
getInstructorTasks.mockImplementation(() => pendingInstructorTaskData);
|
||||
const pendingInstructorTaskData = buildPendingInstructorTaskData(1);
|
||||
getInstructorTasks.mockImplementation(() => pendingInstructorTaskData);
|
||||
|
||||
act(() => {
|
||||
render(<BulkEmailPendingTasks />);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// fast forward time by 31 seconds for the API call to be made to retrieve pending tasks
|
||||
jest.advanceTimersByTime(31000);
|
||||
|
||||
// verify component structure
|
||||
const tableDescription = await screen.findByText(
|
||||
'Email actions run in the background. The status for any active tasks - including email tasks - appears in '
|
||||
+ 'the table below.',
|
||||
);
|
||||
expect(tableDescription).toBeTruthy();
|
||||
|
||||
// verify table structure
|
||||
expect(await screen.findByText('Task Type')).toBeTruthy();
|
||||
expect(await screen.findByText('Task Inputs')).toBeTruthy();
|
||||
expect(await screen.findByText('Task Id')).toBeTruthy();
|
||||
expect(await screen.findByText('Requester')).toBeTruthy();
|
||||
expect(await screen.findByText('Submitted')).toBeTruthy();
|
||||
expect(await screen.findByText('Duration (seconds)')).toBeTruthy();
|
||||
expect(await screen.findByText('State')).toBeTruthy();
|
||||
expect(await screen.findByText('Status')).toBeTruthy();
|
||||
expect(await screen.findByText('Task Progress')).toBeTruthy();
|
||||
expect(await screen.findAllByText('Showing 1 of 1')).toBeTruthy();
|
||||
|
||||
// verification of table contents
|
||||
const { tasks } = pendingInstructorTaskData;
|
||||
const task = tasks[0];
|
||||
expect(await screen.findByText(task.created)).toBeTruthy();
|
||||
expect(await screen.findByText(task.duration_sec)).toBeTruthy();
|
||||
expect(await screen.findByText(task.requester)).toBeTruthy();
|
||||
expect(await screen.findByText(task.status)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_id)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_input)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_message)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_state)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_type)).toBeTruthy();
|
||||
});
|
||||
|
||||
// verify component structure
|
||||
const tableDescription = await screen.findByText(
|
||||
'Email actions run in the background. The status for any active tasks - including email tasks - appears in '
|
||||
+ 'the table below.',
|
||||
);
|
||||
expect(tableDescription).toBeTruthy();
|
||||
|
||||
// verify table structure
|
||||
expect(await screen.findByText('Task Type')).toBeTruthy();
|
||||
expect(await screen.findByText('Task Inputs')).toBeTruthy();
|
||||
expect(await screen.findByText('Task Id')).toBeTruthy();
|
||||
expect(await screen.findByText('Requester')).toBeTruthy();
|
||||
expect(await screen.findByText('Submitted')).toBeTruthy();
|
||||
expect(await screen.findByText('Duration (seconds)')).toBeTruthy();
|
||||
expect(await screen.findByText('State')).toBeTruthy();
|
||||
expect(await screen.findByText('Status')).toBeTruthy();
|
||||
expect(await screen.findByText('Task Progress')).toBeTruthy();
|
||||
expect(await screen.findAllByText('Showing 1 - 1 of 1.')).toBeTruthy();
|
||||
|
||||
// verification of table contents
|
||||
const { tasks } = pendingInstructorTaskData;
|
||||
const task = tasks[0];
|
||||
expect(await screen.findByText(task.created)).toBeTruthy();
|
||||
expect(await screen.findByText(task.duration_sec)).toBeTruthy();
|
||||
expect(await screen.findByText(task.requester)).toBeTruthy();
|
||||
expect(await screen.findByText(task.status)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_id)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_input)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_message)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_state)).toBeTruthy();
|
||||
expect(await screen.findByText(task.task_type)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('renders an error Alert if an error occurs retrieving data', async () => {
|
||||
await act(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers();
|
||||
|
||||
getInstructorTasks.mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
getInstructorTasks.mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
render(<BulkEmailPendingTasks />);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// fast forward time by 31 seconds for the API call to be made to retrieve pending tasks
|
||||
jest.advanceTimersByTime(31000);
|
||||
|
||||
const alertMessage = await screen.findByText(
|
||||
'Error fetching running task data. This request will be retried automatically.',
|
||||
);
|
||||
expect(alertMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
const alertMessage = await screen.findByText(
|
||||
'Error fetching running task data. This request will be retried automatically.',
|
||||
);
|
||||
expect(alertMessage).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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,12 +16,15 @@ jest.mock('../data/api', () => ({
|
||||
|
||||
describe('BulkEmailTaskHistory component', () => {
|
||||
beforeEach(() => jest.resetModules());
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders correctly ', async () => {
|
||||
render(<BulkEmailTaskHistory />);
|
||||
const tableDescription = await screen.findByText(
|
||||
'To see the status for all email tasks submitted for this course, click this button:',
|
||||
'View the status for all email tasks created for this course',
|
||||
);
|
||||
expect(tableDescription).toBeTruthy();
|
||||
const showEmailTaskHistoryButton = await screen.findByText('Show Email Task History');
|
||||
@@ -48,11 +51,12 @@ describe('BulkEmailTaskHistory component', () => {
|
||||
expect(await screen.findByText('State')).toBeTruthy();
|
||||
expect(await screen.findByText('Status')).toBeTruthy();
|
||||
expect(await screen.findByText('Task Progress')).toBeTruthy();
|
||||
expect(await screen.findAllByText('Showing 1 of 1')).toBeTruthy();
|
||||
expect(await screen.findAllByText('Showing 1 - 1 of 1.')).toBeTruthy();
|
||||
// verification of row contents
|
||||
const { tasks } = taskHistoryData;
|
||||
const task = tasks[0];
|
||||
expect(await screen.findByText(task.created)).toBeTruthy();
|
||||
const createdDate = new Date(task.created).toLocaleString();
|
||||
expect(await screen.findByText(createdDate)).toBeTruthy();
|
||||
expect(await screen.findByText(task.duration_sec)).toBeTruthy();
|
||||
expect(await screen.findByText(task.requester)).toBeTruthy();
|
||||
expect(await screen.findByText(task.status)).toBeTruthy();
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export { default } from './BulkEmailTool';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ActionRow, AlertModal, Button } from '@edx/paragon';
|
||||
import { ActionRow, AlertModal, Button } from '@openedx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
function TaskAlertModal(props) {
|
||||
@@ -35,7 +35,15 @@ function TaskAlertModal(props) {
|
||||
id="bulk.email.form.recipients.Contine"
|
||||
defaultMessage="Continue"
|
||||
description="Continue button for the task alert"
|
||||
/>
|
||||
>
|
||||
{ // FormattedMessage wraps the translated string in a <span/> by default. This was
|
||||
// causing strange click event target issues in safari. To solve this, we want to
|
||||
// wrap the string in a fragment instead of a span, so that the whole button considered
|
||||
// a "button" target, and not a "span inside a button"
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
msg => <>{msg}</>
|
||||
}
|
||||
</FormattedMessage>
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
2
src/components/bulk-email-tool/task-alert-modal/index.js
Normal file
2
src/components/bulk-email-tool/task-alert-modal/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export { default } from './TaskAlertModal';
|
||||
@@ -3,7 +3,10 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { render, screen, cleanup } from '../../../setupTest';
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
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 +26,9 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
describe('BulkEmailTool', () => {
|
||||
beforeEach(() => jest.resetModules());
|
||||
beforeAll(async () => {
|
||||
await initializeMockApp();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
/**
|
||||
@@ -31,15 +37,15 @@ describe('BulkEmailTool', () => {
|
||||
*/
|
||||
function buildCourseMetadata(cohortData, courseData) {
|
||||
const {
|
||||
org, number, title, tabs, is_staff: isStaff,
|
||||
} = courseData;
|
||||
org, number, title, tabs, originalUserIsStaff,
|
||||
} = camelCaseObject(courseData);
|
||||
const { cohorts } = cohortData;
|
||||
|
||||
return {
|
||||
org,
|
||||
number,
|
||||
title,
|
||||
isStaff,
|
||||
originalUserIsStaff,
|
||||
tabs: [...tabs],
|
||||
cohorts: cohorts.map(({ name }) => name),
|
||||
};
|
||||
@@ -73,7 +79,7 @@ describe('BulkEmailTool', () => {
|
||||
|
||||
test('BulkEmailTool renders error page on no staff user', async () => {
|
||||
const cohorts = { cohorts: [] };
|
||||
const courseInfo = Factory.build('courseMetadata', { is_staff: false });
|
||||
const courseInfo = Factory.build('courseMetadata', { original_user_is_staff: false });
|
||||
const courseMetadata = buildCourseMetadata(cohorts, courseInfo);
|
||||
renderBulkEmailTool(courseMetadata);
|
||||
// verify error page is displayed for user without staff permissions
|
||||
|
||||
@@ -22,7 +22,7 @@ import contentCss from 'tinymce/skins/content/default/content.css';
|
||||
|
||||
export default function TextEditor(props) {
|
||||
const {
|
||||
onChange, onKeyUp, onInit, disabled,
|
||||
onChange, onKeyUp, onInit, disabled, value,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -33,17 +33,21 @@ export default function TextEditor(props) {
|
||||
height: 600,
|
||||
branding: false,
|
||||
menubar: 'edit view insert format table tools',
|
||||
plugins: 'advlist code link lists table image language codesample',
|
||||
plugins: 'advlist code link lists table image codesample',
|
||||
toolbar:
|
||||
'formatselect fontselect bold italic underline forecolor | codesample bullist numlist alignleft aligncenter alignright alignjustify indent | blockquote link image code | language',
|
||||
'formatselect fontselect bold italic underline forecolor | codesample bullist numlist alignleft aligncenter alignright alignjustify indent | blockquote link image code ',
|
||||
skin: false,
|
||||
content_css: false,
|
||||
content_style: `${contentUiCss.toString()}\n${contentCss.toString()}`,
|
||||
extended_valid_elements: 'span[lang|id] -span',
|
||||
block_unsupported_drop: false,
|
||||
image_advtab: true,
|
||||
name: 'emailBody',
|
||||
relative_urls: false,
|
||||
remove_script_host: false,
|
||||
}}
|
||||
onChange={onChange}
|
||||
onEditorChange={onChange}
|
||||
value={value}
|
||||
onKeyUp={onKeyUp}
|
||||
onInit={onInit}
|
||||
disabled={disabled}
|
||||
@@ -56,6 +60,7 @@ TextEditor.defaultProps = {
|
||||
onKeyUp: () => {},
|
||||
onInit: () => {},
|
||||
disabled: false,
|
||||
value: '',
|
||||
};
|
||||
|
||||
TextEditor.propTypes = {
|
||||
@@ -63,4 +68,5 @@ TextEditor.propTypes = {
|
||||
onKeyUp: PropTypes.func,
|
||||
onInit: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function MockTinyMCE({ onInit }) {
|
||||
const mockedEditor = {
|
||||
getContent: () => 'test body',
|
||||
};
|
||||
onInit({}, mockedEditor);
|
||||
|
||||
return <div />;
|
||||
/**
|
||||
* We represent tinyMCE here as a textarea, because tinyMCE has no support for testing
|
||||
* 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} />;
|
||||
}
|
||||
|
||||
MockTinyMCE.propTypes = {
|
||||
onInit: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default function TextEditor({ onInit }) {
|
||||
return <MockTinyMCE onInit={onInit} />;
|
||||
export default function TextEditor({ onChange }) {
|
||||
return <MockTinyMCE onChange={onChange} />;
|
||||
}
|
||||
|
||||
TextEditor.propTypes = {
|
||||
onInit: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export { default } from './TextEditor';
|
||||
|
||||
33
src/components/navigation-tabs/BackToInstructor.jsx
Normal file
33
src/components/navigation-tabs/BackToInstructor.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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 '@openedx/paragon';
|
||||
import { ArrowBack } from '@openedx/paragon/icons';
|
||||
|
||||
export default function BackToInstructor(props) {
|
||||
const { courseId } = props;
|
||||
|
||||
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,
|
||||
};
|
||||
33
src/components/navigation-tabs/BackToInstructor.test.jsx
Normal file
33
src/components/navigation-tabs/BackToInstructor.test.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,19 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Nav } from '@edx/paragon';
|
||||
import { Nav } from '@openedx/paragon';
|
||||
|
||||
export default function NavigationTabs(props) {
|
||||
const { tabData } = props;
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<Nav>
|
||||
<Nav variant="tabs" defaultActiveKey="Instructor">
|
||||
{tabData && tabData.map(tab => (
|
||||
<Nav.Item key={tab.tab_id}>
|
||||
<Nav.Link eventKey={tab.url} href={tab.url} className="mx-3 py-2">{tab.title}</Nav.Link>
|
||||
<Nav.Link eventKey={tab.title} href={tab.url}>
|
||||
{tab.title}
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
))}
|
||||
</Nav>
|
||||
|
||||
@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { Spinner } from '@edx/paragon';
|
||||
import FooterSlot from '@openedx/frontend-slot-footer';
|
||||
import { Spinner } from '@openedx/paragon';
|
||||
|
||||
import { getCohorts, getCourseHomeCourseMetadata } from './data/api';
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function PageContainer(props) {
|
||||
org: '',
|
||||
number: '',
|
||||
title: '',
|
||||
isStaff: false,
|
||||
originalUserIsStaff: false,
|
||||
tabs: [],
|
||||
cohorts: [],
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export default function PageContainer(props) {
|
||||
}
|
||||
|
||||
const {
|
||||
org, number, title, tabs, is_staff: isStaff,
|
||||
org, number, title, tabs, originalUserIsStaff, courseModes,
|
||||
} = metadataResponse;
|
||||
const { cohorts } = cohortsResponse;
|
||||
|
||||
@@ -47,12 +47,14 @@ export default function PageContainer(props) {
|
||||
org,
|
||||
number,
|
||||
title,
|
||||
isStaff,
|
||||
originalUserIsStaff,
|
||||
courseModes,
|
||||
tabs: [...tabs],
|
||||
cohorts: cohorts.map(({ name }) => name),
|
||||
});
|
||||
}
|
||||
fetchCourseMetadata();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (courseMetadata) {
|
||||
@@ -65,8 +67,12 @@ export default function PageContainer(props) {
|
||||
courseNumber={courseMetadata.number}
|
||||
courseTitle={courseMetadata.title}
|
||||
/>
|
||||
{children}
|
||||
<Footer />
|
||||
<div className="pb-3 container">
|
||||
<main id="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<FooterSlot />
|
||||
</>
|
||||
</CourseMetadataContext.Provider>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export default Factory.define('courseMetadata')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
is_staff: true,
|
||||
original_user_is_staff: false,
|
||||
original_user_is_staff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
title: 'Demonstration Course',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const courseHomeBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
|
||||
export const getCourseHomeBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
const courseHomeMetadataUrl = `${courseHomeBaseUrl}/${courseId}`;
|
||||
const courseHomeMetadataUrl = `${getCourseHomeBaseUrl()}/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(courseHomeMetadataUrl);
|
||||
return data;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function getCohorts(courseId) {
|
||||
|
||||
24
src/components/page-container/data/api.test.js
Normal file
24
src/components/page-container/data/api.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
1
src/i18n/index.js
Normal file
1
src/i18n/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default [];
|
||||
@@ -1,32 +0,0 @@
|
||||
import arMessages from './messages/ar.json';
|
||||
import caMessages from './messages/ca.json';
|
||||
// no need to import en messages-- they are in the defaultMessage field
|
||||
import es419Messages from './messages/es_419.json';
|
||||
import frMessages from './messages/fr.json';
|
||||
import zhcnMessages from './messages/zh_CN.json';
|
||||
import heMessages from './messages/he.json';
|
||||
import idMessages from './messages/id.json';
|
||||
import kokrMessages from './messages/ko_kr.json';
|
||||
import plMessages from './messages/pl.json';
|
||||
import ptbrMessages from './messages/pt_br.json';
|
||||
import ruMessages from './messages/ru.json';
|
||||
import thMessages from './messages/th.json';
|
||||
import ukMessages from './messages/uk.json';
|
||||
|
||||
const messages = {
|
||||
ar: arMessages,
|
||||
'es-419': es419Messages,
|
||||
fr: frMessages,
|
||||
'zh-cn': zhcnMessages,
|
||||
ca: caMessages,
|
||||
he: heMessages,
|
||||
id: idMessages,
|
||||
'ko-kr': kokrMessages,
|
||||
pl: plMessages,
|
||||
'pt-br': ptbrMessages,
|
||||
ru: ruMessages,
|
||||
th: thMessages,
|
||||
uk: ukMessages,
|
||||
};
|
||||
|
||||
export default messages;
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -2,16 +2,14 @@ import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import {
|
||||
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig,
|
||||
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, getConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { AppProvider, AuthenticatedPageRoute, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { messages as headerMessages } from '@edx/frontend-component-header';
|
||||
import { messages as footerMessages } from '@edx/frontend-component-footer';
|
||||
|
||||
import { Switch } from 'react-router-dom';
|
||||
import appMessages from './i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import messages from './i18n';
|
||||
|
||||
import './index.scss';
|
||||
import BulkEmailTool from './components/bulk-email-tool';
|
||||
@@ -20,15 +18,21 @@ import PageContainer from './components/page-container/PageContainer';
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
<AppProvider>
|
||||
<div className="pb-3 container">
|
||||
<Switch>
|
||||
<AuthenticatedPageRoute path="/courses/:courseId/bulk_email">
|
||||
<PageContainer>
|
||||
<BulkEmailTool />
|
||||
</PageContainer>
|
||||
</AuthenticatedPageRoute>
|
||||
</Switch>
|
||||
</div>
|
||||
<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>
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
@@ -39,13 +43,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,
|
||||
});
|
||||
|
||||
@@ -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 "~@openedx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
@import "~@edx/frontend-component-footer/dist/footer";
|
||||
|
||||
50
src/plugin-slots/FooterSlot/README.md
Normal file
50
src/plugin-slots/FooterSlot/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Footer Slot
|
||||
|
||||
### Slot ID: `footer_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the footer.
|
||||
|
||||
The implementation of the `FooterSlot` component lives in [the `frontend-slot-footer` repository](https://github.com/openedx/frontend-slot-footer/).
|
||||
|
||||
## Example
|
||||
|
||||
The following `env.config.jsx` will replace the default footer.
|
||||
|
||||

|
||||
|
||||
with a simple custom footer
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
footer_slot: {
|
||||
plugins: [
|
||||
{
|
||||
// Hide the default footer
|
||||
op: PLUGIN_OPERATIONS.Hide,
|
||||
widgetId: 'default_contents',
|
||||
},
|
||||
{
|
||||
// Insert a custom footer
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_footer',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🦶</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
BIN
src/plugin-slots/FooterSlot/images/custom_footer.png
Normal file
BIN
src/plugin-slots/FooterSlot/images/custom_footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user