Compare commits

..

6 Commits

Author SHA1 Message Date
Adolfo R. Brandes
ca7bc7d359 feat: Runtime config support, take 2
Adds a couple of missing features for proper runtime configuration:

1. Favicon runtime configuration support via react-helmet

2. Placeholder values for APP_ID and MFE_CONFIG_API_URL in the sample
   .env files
2023-06-14 15:33:56 +01:00
Adolfo R. Brandes
b41a336e11 feat: Support runtime configuration
frontend-platform supports runtime configuration since 2.5.0 (see the PR
that introduced it[1], but it requires MFE cooperation.  This implements
just that: by avoiding making configuration values constant, it should
now be possible to change them after initialization.

Only a single change related to the `LMS_BASE_URL` setting was required.

[1] openedx/frontend-platform#335
2023-06-13 21:27:27 +01:00
Tobias Macey
b4032215c6 fix: Disable URL rewriting when creating links
The default behavior of the TinyMCE editor is to rewrite links that share the same
domain as the component to be relative to that path. Relative URLs will never work in
email contents, so they _always_ need to be absolute URLs. This adds the configuration
settings for `relative_urls` and `remove_script_host` in TinyMCE to always be false,
enabling it to always use absolute URLs. See
[here](https://www.tiny.cloud/docs/configure/url-handling/) for reference.
2023-06-13 13:08:14 +01:00
Ghassan Maslamani
b20eb50699 test: course url when public path is set
The commit add two tests for the following componenets:

  1. BackToInstructor
  2. BulkEmailPendingTasksAlert

  Which tests course url when public path is set to something
  other than '/' and also when it is '/'.
2023-06-06 16:55:14 +01:00
Ghassan Maslamani
5c021cdc80 fix: getting course-id when public path is set, closes #126
This change change the way course-id is retrieved, in
  1. BackToInstructor
  2. BulkEmailPendingTasksAlert
  componenets, before it was resolved by guessing course-id
  index in the url, which would not be true if the public
  path is set something other than '/'.
  Since public path would shift the index of course-id
  in the url.

  Instead the course-id is resolved through react-router just
  like the container componenet, using the `useParams` hook.
2023-06-06 16:55:14 +01:00
Mashal Malik
fa826fe687 feat: upgrade to node v18 and related fixes (#123) 2023-06-02 12:17:47 +01:00
71 changed files with 15570 additions and 13680 deletions

2
.env
View File

@@ -21,5 +21,3 @@ USER_INFO_COOKIE_NAME=''
SCHEDULE_EMAIL_SECTION='' SCHEDULE_EMAIL_SECTION=''
APP_ID='' APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -22,5 +22,3 @@ USER_INFO_COOKIE_NAME='edx-user-info'
SCHEDULE_EMAIL_SECTION='true' SCHEDULE_EMAIL_SECTION='true'
APP_ID='' APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -20,4 +20,3 @@ USER_INFO_COOKIE_NAME='edx-user-info'
SCHEDULE_EMAIL_SECTION='true' SCHEDULE_EMAIL_SECTION='true'
APP_ID='' APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}

View File

@@ -1,6 +1,6 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
const { createConfig } = require('@openedx/frontend-build'); const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint', { module.exports = createConfig('eslint', {
rules: { rules: {

View File

@@ -18,9 +18,9 @@ jobs:
- name: Setup Nodejs Env - name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs - name: Setup Nodejs
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version-file: '.nvmrc' node-version: ${{ env.NODE_VER }}
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Validate package-lock.json changes - name: Validate package-lock.json changes
@@ -34,7 +34,4 @@ jobs:
- name: i18n_extract - name: i18n_extract
run: npm run i18n_extract run: npm run i18n_extract
- name: Coverage - name: Coverage
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true

View File

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

2
.gitignore vendored
View File

@@ -5,7 +5,6 @@ node_modules
npm-debug.log npm-debug.log
coverage coverage
module.config.js module.config.js
env.config.*
dist/ dist/
src/i18n/transifex_input.json src/i18n/transifex_input.json
@@ -18,4 +17,3 @@ temp/babel-plugin-react-intl
*~ *~
/temp /temp
/.vscode /.vscode
src/i18n/messages/

3
.nvmrc
View File

@@ -1 +1,2 @@
24 18

8
.tx/config Normal file
View File

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

View File

@@ -1,17 +1,19 @@
intl_imports = ./node_modules/.bin/intl-imports.js export TRANSIFEX_RESOURCE = frontend-app-communications
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc . # This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs transifex_temp = ./temp/babel-plugin-react-intl
precommit: precommit:
npm run lint npm run lint
npm audit npm audit
requirements: requirements:
npm ci npm install
i18n.extract: i18n.extract:
# Pulling display strings from .jsx files into .json files... # Pulling display strings from .jsx files into .json files...
@@ -29,18 +31,20 @@ detect_changed_source_translations:
# Checking for changed translations... # Checking for changed translations...
git diff --exit-code $(i18n) git diff --exit-code $(i18n)
pull_translations: # Pushes translations to Transifex. You must run make extract_translations first.
rm -rf src/i18n/messages push_translations:
mkdir src/i18n/messages # Pushing strings to Transifex...
cd src/i18n/messages \ tx push -s
&& atlas pull $(ATLAS_OPTIONS) \ # Fetching hashes from Transifex...
translations/frontend-component-header/src/i18n/messages:frontend-component-header \ ./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ # Writing out comments to file...
translations/paragon/src/i18n/messages:paragon \ $(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
translations/frontend-platform/src/i18n/messages:frontend-platform \ # Pushing comments to Transifex...
translations/frontend-app-communications/src/i18n/messages:frontend-app-communications ./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-platform frontend-app-communications # Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
# This target is used by Travis. # This target is used by Travis.
validate-no-uncommitted-package-lock-changes: validate-no-uncommitted-package-lock-changes:

View File

@@ -1,72 +1,55 @@
|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 frontend-app-communications
########################### ==============================
|license-badge| |status-badge| |ci-badge| |codecov-badge| Please tag **edx-aperture** on any PRs or issues. Thanks!
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 Getting started
******** ------------
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. 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.
Getting Started 1. Clone the repo into your usual workspace
***************
Prerequisites .. code-block::
=============
`Tutor`_ is currently recommended as a development environment for your mkdir -p ~/workspace/
new MFE. Please refer cd ~/workspace/
to the `relevant tutor-mfe documentation`_ to get started using it. git clone https://github.com/edx/frontend-app-communications.git
.. _Tutor: https://github.com/overhangio/tutor 2. Install frontend dependencies
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development .. code-block::
npm i
Cloning and Startup 3. Start the devserver. The app will be running at ``localhost:1984``, or whatever port you change it too.
===================
1. Clone your new repo: .. code-block::
.. code-block:: bash
git clone https://github.com/edx/frontend-app-communications.git
2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
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. Install npm dependencies:
.. 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 npm start
Environment Variables/Setup Notes Environment Variables/Setup Notes
--------------------------------- ---------------------------------
If you wish to add new environment variables for local testing, they should be listed in 2 places: If you wish to add new environment varibles for local testing, they should be listed in 2 places:
1. In ``.env.development`` 1. In ``.env.development``
2. Added to the ``mergeConfig`` found in ``src/index.jsx`` 2. Added to the ``mergeConfig`` found in ``src/index.jsx``
.. code-block:: jsx .. code-block::
initialize({ initialize({
config: () => { config: () => {
@@ -75,108 +58,10 @@ If you wish to add new environment variables for local testing, they should be l
}, 'CommuncationsAppConfig'); }, 'CommuncationsAppConfig');
Running Tests Running Tests
------------- ---------------------------
Tests use `jest` and `react-test-library`. To run all the tests for this repo: 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

View File

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

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@openedx/frontend-build'); const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', { module.exports = createConfig('jest', {
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want. // setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.

View File

@@ -23,9 +23,9 @@ module.exports = {
**********************************************************************************************/ **********************************************************************************************/
// { moduleName: '@edx/brand', dir: '../brand-openedx' }, // replace with your brand checkout // { moduleName: '@edx/brand', dir: '../brand-openedx' }, // replace with your brand checkout
// { moduleName: '@openedx/paragon/scss/core', dir: '../paragon', dist: 'scss/core' }, // { moduleName: '@edx/paragon/scss/core', dir: '../paragon', dist: 'scss/core' },
// { moduleName: '@openedx/paragon/icons', dir: '../paragon', dist: 'icons' }, // { moduleName: '@edx/paragon/icons', dir: '../paragon', dist: 'icons' },
// { moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' }, // { moduleName: '@edx/paragon', dir: '../paragon', dist: 'dist' },
// { moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' }, // { moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
], ],
}; };

9
openedx.yaml Normal file
View File

@@ -0,0 +1,9 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
oeps: {}
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

27925
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,18 @@
], ],
"scripts": { "scripts": {
"build": "fedx-scripts webpack", "build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract", "i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .js --ext .jsx src/", "lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx src/", "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot", "snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress", "start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/communications/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests" "test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
}, },
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-communications#readme", "homepage": "https://github.com/edx/frontend-app-communications#readme",
@@ -29,19 +33,17 @@
"url": "https://github.com/edx/frontend-app-communications/issues" "url": "https://github.com/edx/frontend-app-communications/issues"
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "^14.6.0", "@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-component-header": "^6.6.1", "@edx/frontend-component-header": "^4.0.0",
"@edx/frontend-platform": "^8.3.7", "@edx/frontend-platform": "^4.0.1",
"@edx/openedx-atlas": "^0.6.0", "@edx/paragon": "^20.20.0",
"@edx/tinymce-language-selector": "1.1.0", "@edx/tinymce-language-selector": "1.1.0",
"@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0", "@fortawesome/react-fontawesome": "0.2.0",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/paragon": "^23.3.0",
"@tinymce/tinymce-react": "3.14.0", "@tinymce/tinymce-react": "3.14.0",
"axios": "0.27.2", "axios": "0.27.2",
"classnames": "2.3.2", "classnames": "2.3.2",
@@ -49,25 +51,26 @@
"jquery": "3.6.1", "jquery": "3.6.1",
"popper.js": "1.16.1", "popper.js": "1.16.1",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"react": "^18.3.1", "react": "16.14.0",
"react-dom": "^18.3.1", "react-dom": "16.14.0",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-redux": "7.2.9", "react-redux": "7.2.9",
"react-router": "6.15.0", "react-router": "5.3.4",
"react-router-dom": "6.15.0", "react-router-dom": "5.3.4",
"redux": "4.2.0", "redux": "4.2.0",
"regenerator-runtime": "0.13.11", "regenerator-runtime": "0.13.11",
"tinymce": "5.10.7" "tinymce": "5.10.7"
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "^1.2.0", "@edx/browserslist-config": "^1.2.0",
"@edx/typescript-config": "^1.1.0", "@edx/frontend-build": "^12.7.0",
"@openedx/frontend-build": "^14.6.2", "@edx/reactifex": "^2.1.1",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "^16.2.0", "@testing-library/react": "12.1.5",
"axios-mock-adapter": "1.21.2", "axios-mock-adapter": "1.21.2",
"glob": "7.2.3", "glob": "7.2.3",
"jest": "29.7.0", "husky": "7.0.4",
"jest": "27.5.1",
"prettier": "2.8.1", "prettier": "2.8.1",
"rosie": "2.1.0" "rosie": "2.1.0"
} }

View File

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

View File

@@ -24,7 +24,7 @@
"automerge": true "automerge": true
}, },
{ {
"matchPackagePatterns": ["@edx", "@openedx"], "matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"automerge": true "automerge": true
} }

View File

@@ -1,38 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<UNDEFINED>
<ErrorPage
message="test-error-message"
/>
</UNDEFINED>
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<UNDEFINED>
<AppProvider>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<link
href="favicon-url"
rel="shortcut icon"
type="image/x-icon"
/>
</HelmetWrapper>
<Routes>
<Route
element={
<AuthenticatedPageRoute>
<Page Container>
<Bulk Email Tool />
</Page Container>
</AuthenticatedPageRoute>
}
path="/courses/:courseId/bulk_email"
/>
</Routes>
</AppProvider>
</UNDEFINED>
`;

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ErrorPage } from '@edx/frontend-platform/react'; import { ErrorPage } from '@edx/frontend-platform/react';
import { Container } from '@openedx/paragon'; import { Container } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import BulkEmailTaskManager from './bulk-email-task-manager/BulkEmailTaskManager'; import BulkEmailTaskManager from './bulk-email-task-manager/BulkEmailTaskManager';
import NavigationTabs from '../navigation-tabs/NavigationTabs'; import NavigationTabs from '../navigation-tabs/NavigationTabs';
@@ -24,7 +24,7 @@ export default function BulkEmailTool() {
<Container size="md"> <Container size="md">
<BackToInstructor courseId={courseId} /> <BackToInstructor courseId={courseId} />
<div className="row pb-4.5"> <div className="row pb-4.5">
<h1 className="text-primary-500"> <h1 className="text-primary-500" id="main-content">
<FormattedMessage <FormattedMessage
id="bulk.email.send.email.header" id="bulk.email.send.email.header"
defaultMessage="Send an email" defaultMessage="Send an email"
@@ -33,11 +33,7 @@ export default function BulkEmailTool() {
</h1> </h1>
</div> </div>
<div className="row"> <div className="row">
<BulkEmailForm <BulkEmailForm courseId={courseId} cohorts={courseMetadata.cohorts} />
courseId={courseId}
cohorts={courseMetadata.cohorts}
courseModes={courseMetadata.courseModes}
/>
</div> </div>
<div className="row py-5"> <div className="row py-5">
<BulkEmailTaskManager courseId={courseId} /> <BulkEmailTaskManager courseId={courseId} />

View File

@@ -4,11 +4,11 @@ import PropTypes from 'prop-types';
import { import {
Button, Button,
Form, Icon, StatefulButton, Toast, useToggle, Form, Icon, StatefulButton, Toast, useToggle,
} from '@openedx/paragon'; } from '@edx/paragon';
import { import {
SpinnerSimple, Cancel, Send, Event, Check, SpinnerSimple, Cancel, Send, Event, Check,
} from '@openedx/paragon/icons'; } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import classNames from 'classnames'; import classNames from 'classnames';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import TextEditor from '../text-editor/TextEditor'; import TextEditor from '../text-editor/TextEditor';
@@ -47,12 +47,7 @@ const FORM_ACTIONS = {
}; };
function BulkEmailForm(props) { function BulkEmailForm(props) {
const { const { courseId, cohorts, intl } = props;
courseId,
cohorts,
courseModes,
} = props;
const intl = useIntl();
const [{ editor }, dispatch] = useContext(BulkEmailContext); const [{ editor }, dispatch] = useContext(BulkEmailContext);
const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT); const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT);
const [emailFormValidation, setEmailFormValidation] = useState({ const [emailFormValidation, setEmailFormValidation] = useState({
@@ -277,14 +272,10 @@ function BulkEmailForm(props) {
handleCheckboxes={onRecipientChange} handleCheckboxes={onRecipientChange}
additionalCohorts={cohorts} additionalCohorts={cohorts}
isValid={emailFormValidation.recipients} isValid={emailFormValidation.recipients}
courseModes={courseModes}
/> />
<Form.Group controlId="emailSubject"> <Form.Group controlId="emailSubject">
<Form.Label className="h3 text-primary-500">{intl.formatMessage(messages.bulkEmailSubjectLabel)}</Form.Label> <Form.Label className="h3 text-primary-500">{intl.formatMessage(messages.bulkEmailSubjectLabel)}</Form.Label>
<Form.Control name="emailSubject" className="w-lg-50" onChange={onFormChange} value={editor.emailSubject} maxLength={128} /> <Form.Control name="emailSubject" className="w-lg-50" onChange={onFormChange} value={editor.emailSubject} />
<Form.Control.Feedback className="px-3" type="default">
{intl.formatMessage(messages.bulkEmailFormSubjectTip)}
</Form.Control.Feedback>
{!emailFormValidation.subject && ( {!emailFormValidation.subject && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid"> <Form.Control.Feedback className="px-3" hasIcon type="invalid">
{intl.formatMessage(messages.bulkEmailFormSubjectError)} {intl.formatMessage(messages.bulkEmailFormSubjectError)}
@@ -392,13 +383,7 @@ BulkEmailForm.defaultProps = {
BulkEmailForm.propTypes = { BulkEmailForm.propTypes = {
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.string), cohorts: PropTypes.arrayOf(PropTypes.string),
intl: intlShape.isRequired,
courseModes: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
).isRequired,
}; };
export default BulkEmailForm; export default injectIntl(BulkEmailForm);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,20 @@
/* eslint-disable react/no-unstable-nested-components */ /* eslint-disable react/no-unstable-nested-components */
import React, { useMemo, useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { import {
Button, Collapsible, Icon, Button, Collapsible, Icon,
} from '@openedx/paragon'; } from '@edx/paragon';
import { SpinnerSimple } from '@openedx/paragon/icons'; import { SpinnerSimple } from '@edx/paragon/icons';
import messages from './messages'; import messages from './messages';
import { getSentEmailHistory } from './data/api'; import { getSentEmailHistory } from './data/api';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable'; import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
import ViewEmailModal from './ViewEmailModal'; import ViewEmailModal from './ViewEmailModal';
function BulkEmailContentHistory() { function BulkEmailContentHistory({ intl }) {
const intl = useIntl();
const { courseId } = useParams(); const { courseId } = useParams();
const [emailHistoryData, setEmailHistoryData] = useState(); const [emailHistoryData, setEmailHistoryData] = useState();
const [errorRetrievingData, setErrorRetrievingData] = useState(false); const [errorRetrievingData, setErrorRetrievingData] = useState(false);
@@ -52,15 +51,17 @@ function BulkEmailContentHistory() {
* up a level (the `subject` field). We also convert the `sent_to` data to be a String rather than an array to fix a * 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. * display bug in the table.
*/ */
const transformDataForTable = useMemo(() => { function transformDataForTable() {
const tableData = emailHistoryData?.map((item) => ({ let tableData = [];
...item, if (emailHistoryData) {
subject: item.email.subject, tableData = emailHistoryData.map((item) => ({
sent_to: item.sent_to.join(', '), ...item,
created: new Date(item.created).toLocaleString(), subject: item.email.subject,
})); sent_to: item.sent_to.join(', '),
return tableData || []; }));
}, [emailHistoryData]); }
return tableData;
}
/** /**
* This function is responsible for setting the current `messageContent` state data. This will be the contents of a * This function is responsible for setting the current `messageContent` state data. This will be the contents of a
@@ -101,7 +102,7 @@ function BulkEmailContentHistory() {
* contents of a previously sent message. * contents of a previously sent message.
*/ */
const additionalColumns = () => { const additionalColumns = () => {
const tableData = transformDataForTable; const tableData = transformDataForTable();
return [ return [
{ {
@@ -138,7 +139,7 @@ function BulkEmailContentHistory() {
{showHistoricalEmailContentTable ? ( {showHistoricalEmailContentTable ? (
<BulkEmailTaskManagerTable <BulkEmailTaskManagerTable
errorRetrievingData={errorRetrievingData} errorRetrievingData={errorRetrievingData}
tableData={transformDataForTable} tableData={transformDataForTable()}
tableDescription={intl.formatMessage(messages.emailHistoryTableViewMessageInstructions)} tableDescription={intl.formatMessage(messages.emailHistoryTableViewMessageInstructions)}
alertWarningMessage={intl.formatMessage(messages.noEmailData)} alertWarningMessage={intl.formatMessage(messages.noEmailData)}
alertErrorMessage={intl.formatMessage(messages.errorFetchingEmailHistoryData)} alertErrorMessage={intl.formatMessage(messages.errorFetchingEmailHistoryData)}
@@ -155,6 +156,7 @@ function BulkEmailContentHistory() {
} }
BulkEmailContentHistory.propTypes = { BulkEmailContentHistory.propTypes = {
intl: intlShape.isRequired,
row: PropTypes.shape({ row: PropTypes.shape({
index: PropTypes.number, index: PropTypes.number,
}), }),
@@ -164,4 +166,4 @@ BulkEmailContentHistory.defaultProps = {
row: {}, row: {},
}; };
export default BulkEmailContentHistory; export default injectIntl(BulkEmailContentHistory);

View File

@@ -1,4 +1,4 @@
import { Alert, DataTable } from '@openedx/paragon'; import { Alert, DataTable } from '@edx/paragon';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';

View File

@@ -1,14 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getInstructorTasks } from './data/api'; import { getInstructorTasks } from './data/api';
import messages from './messages'; import messages from './messages';
import useInterval from '../../../utils/useInterval'; import useInterval from '../../../utils/useInterval';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable'; import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
function BulkEmailPendingTasks() { function BulkEmailPendingTasks({ intl }) {
const intl = useIntl();
const { courseId } = useParams(); const { courseId } = useParams();
const [instructorTaskData, setInstructorTaskData] = useState(); const [instructorTaskData, setInstructorTaskData] = useState();
@@ -90,4 +89,8 @@ function BulkEmailPendingTasks() {
); );
} }
export default BulkEmailPendingTasks; BulkEmailPendingTasks.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BulkEmailPendingTasks);

View File

@@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { Hyperlink, Alert } from '@openedx/paragon'; import { Hyperlink, Alert } from '@edx/paragon';
import { WarningFilled } from '@openedx/paragon/icons'; import { WarningFilled } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
export default function BulkEmailPendingTasksAlert(props) { export default function BulkEmailPendingTasksAlert(props) {

View File

@@ -1,9 +1,9 @@
import React, { useMemo, useState } from 'react'; import React, { useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, Collapsible } from '@openedx/paragon'; import { Icon, Collapsible } from '@edx/paragon';
import { SpinnerSimple } from '@openedx/paragon/icons'; import { SpinnerSimple } from '@edx/paragon/icons';
import { getEmailTaskHistory } from './data/api'; import { getEmailTaskHistory } from './data/api';
import messages from './messages'; import messages from './messages';
@@ -11,8 +11,7 @@ import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
import './bulkEmailTaskHistory.scss'; import './bulkEmailTaskHistory.scss';
function BulkEmailTaskHistory() { function BulkEmailTaskHistory({ intl }) {
const intl = useIntl();
const { courseId } = useParams(); const { courseId } = useParams();
const [emailTaskHistoryData, setEmailTaskHistoryData] = useState([]); const [emailTaskHistoryData, setEmailTaskHistoryData] = useState([]);
@@ -42,14 +41,6 @@ function BulkEmailTaskHistory() {
setShowHistoricalTaskContentTable(true); setShowHistoricalTaskContentTable(true);
} }
const transformDataForTable = useMemo(() => {
const tableData = emailTaskHistoryData?.map((item) => ({
...item,
created: new Date(item.created).toLocaleString(),
}));
return tableData || [];
}, [emailTaskHistoryData]);
const tableColumns = [ const tableColumns = [
{ {
Header: `${intl.formatMessage(messages.taskHistoryTableColumnHeaderTaskType)}`, Header: `${intl.formatMessage(messages.taskHistoryTableColumnHeaderTaskType)}`,
@@ -104,7 +95,7 @@ function BulkEmailTaskHistory() {
{showHistoricalTaskContentTable ? ( {showHistoricalTaskContentTable ? (
<BulkEmailTaskManagerTable <BulkEmailTaskManagerTable
errorRetrievingData={errorRetrievingData} errorRetrievingData={errorRetrievingData}
tableData={transformDataForTable} tableData={emailTaskHistoryData}
alertWarningMessage={intl.formatMessage(messages.noTaskHistoryData)} alertWarningMessage={intl.formatMessage(messages.noTaskHistoryData)}
alertErrorMessage={intl.formatMessage(messages.errorFetchingTaskHistoryData)} alertErrorMessage={intl.formatMessage(messages.errorFetchingTaskHistoryData)}
columns={tableColumns} columns={tableColumns}
@@ -118,4 +109,8 @@ function BulkEmailTaskHistory() {
); );
} }
export default BulkEmailTaskHistory; BulkEmailTaskHistory.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BulkEmailTaskHistory);

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import BulkEmailContentHistory from './BulkEmailContentHistory'; import BulkEmailContentHistory from './BulkEmailContentHistory';
import BulkEmailTaskHistory from './BulkEmailTaskHistory'; import BulkEmailTaskHistory from './BulkEmailTaskHistory';
@@ -9,8 +9,7 @@ import messages from './messages';
import BulkEmailScheduledEmailsTable from './bulk-email-scheduled-emails-table'; import BulkEmailScheduledEmailsTable from './bulk-email-scheduled-emails-table';
import BulkEmailPendingTasksAlert from './BulkEmailPendingTasksAlert'; import BulkEmailPendingTasksAlert from './BulkEmailPendingTasksAlert';
function BulkEmailTaskManager({ courseId }) { function BulkEmailTaskManager({ intl, courseId }) {
const intl = useIntl();
return ( return (
<div className="w-100"> <div className="w-100">
{getConfig().SCHEDULE_EMAIL_SECTION && ( {getConfig().SCHEDULE_EMAIL_SECTION && (
@@ -35,7 +34,8 @@ function BulkEmailTaskManager({ courseId }) {
} }
BulkEmailTaskManager.propTypes = { BulkEmailTaskManager.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
}; };
export default BulkEmailTaskManager; export default injectIntl(BulkEmailTaskManager);

View File

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

View File

@@ -4,13 +4,13 @@
import React, { import React, {
useCallback, useContext, useState, useEffect, useCallback, useContext, useState, useEffect,
} from 'react'; } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { import {
Alert, DataTable, Icon, IconButton, useToggle, Alert, DataTable, Icon, IconButton, useToggle,
} from '@openedx/paragon'; } from '@edx/paragon';
import { import {
Delete, Info, Visibility, Edit, Delete, Info, Visibility, Edit,
} from '@openedx/paragon/icons'; } from '@edx/paragon/icons';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { BulkEmailContext } from '../../bulk-email-context'; import { BulkEmailContext } from '../../bulk-email-context';
import { deleteScheduledEmailThunk, getScheduledBulkEmailThunk } from './data/thunks'; import { deleteScheduledEmailThunk, getScheduledBulkEmailThunk } from './data/thunks';
@@ -26,14 +26,12 @@ function flattenScheduledEmailsArray(emails) {
emailId: email.courseEmail.id, emailId: email.courseEmail.id,
task: email.task, task: email.task,
taskDue: new Date(email.taskDue).toLocaleString(), taskDue: new Date(email.taskDue).toLocaleString(),
taskDueUTC: email.taskDue,
...email.courseEmail, ...email.courseEmail,
targets: email.courseEmail.targets.join(', '), targets: email.courseEmail.targets.join(', '),
})); }));
} }
function BulkEmailScheduledEmailsTable() { function BulkEmailScheduledEmailsTable({ intl }) {
const intl = useIntl();
const { courseId } = useParams(); const { courseId } = useParams();
const [{ scheduledEmailsTable }, dispatch] = useContext(BulkEmailContext); const [{ scheduledEmailsTable }, dispatch] = useContext(BulkEmailContext);
const [tableData, setTableData] = useState([]); const [tableData, setTableData] = useState([]);
@@ -93,10 +91,10 @@ function BulkEmailScheduledEmailsTable() {
const handleEditEmail = (row) => { const handleEditEmail = (row) => {
const { const {
original: { original: {
htmlMessage: emailBody, subject: emailSubject, taskDueUTC, targets, schedulingId, emailId, htmlMessage: emailBody, subject: emailSubject, taskDue, targets, schedulingId, emailId,
}, },
} = row; } = row;
const dateTime = new Date(taskDueUTC); const dateTime = new Date(taskDue);
const emailRecipients = targets.replaceAll('-', ':').split(', '); const emailRecipients = targets.replaceAll('-', ':').split(', ');
const scheduleDate = formatDate(dateTime); const scheduleDate = formatDate(dateTime);
const scheduleTime = formatTime(dateTime); const scheduleTime = formatTime(dateTime);
@@ -197,4 +195,8 @@ function BulkEmailScheduledEmailsTable() {
); );
} }
export default BulkEmailScheduledEmailsTable; BulkEmailScheduledEmailsTable.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BulkEmailScheduledEmailsTable);

View File

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

View File

@@ -3,7 +3,7 @@
*/ */
import React from 'react'; import React from 'react';
import { import {
render, screen, fireEvent, cleanup, initializeMockApp, render, screen, fireEvent, cleanup, act, initializeMockApp,
} from '../../../../setupTest'; } from '../../../../setupTest';
import { BulkEmailProvider } from '../../bulk-email-context'; import { BulkEmailProvider } from '../../bulk-email-context';
import BulkEmailContentHistory from '../BulkEmailContentHistory'; import BulkEmailContentHistory from '../BulkEmailContentHistory';
@@ -41,99 +41,105 @@ describe('BulkEmailContentHistory component', () => {
}); });
test('renders a table when the button is pressed and data is returned', async () => { test('renders a table when the button is pressed and data is returned', async () => {
const emailHistoryData = buildEmailContentHistoryData(1); await act(async () => {
getSentEmailHistory.mockImplementation(() => emailHistoryData); const emailHistoryData = buildEmailContentHistoryData(1);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
render(renderBulkEmailContentHistory()); render(renderBulkEmailContentHistory());
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History'); const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton); fireEvent.click(showEmailContentHistoryButton);
// verify component structure // verify component structure
const tableDescription = await screen.findByText( const tableDescription = await screen.findByText(
'To read a sent email message, click the `View Message` button within the table.', 'To read a sent email message, click the `View Message` button within the table.',
); );
expect(tableDescription).toBeTruthy(); expect(tableDescription).toBeTruthy();
// verify table structure // verify table structure
expect(await screen.findByText('Subject')).toBeTruthy(); expect(await screen.findByText('Subject')).toBeTruthy();
expect(await screen.findByText('Sent By')).toBeTruthy(); expect(await screen.findByText('Sent By')).toBeTruthy();
expect(await screen.findByText('Sent To')).toBeTruthy(); expect(await screen.findByText('Sent To')).toBeTruthy();
expect(await screen.findByText('Time Sent')).toBeTruthy(); expect(await screen.findByText('Time Sent')).toBeTruthy();
expect(await screen.findByText('Number Sent')).toBeTruthy(); expect(await screen.findByText('Number Sent')).toBeTruthy();
// verify table contents // verify table contents
const { emails } = emailHistoryData; const { emails } = emailHistoryData;
const email = emails[0]; const email = emails[0];
const createdDate = new Date(email.created).toLocaleString(); expect(await screen.findByText(email.created)).toBeTruthy();
expect(await screen.findByText(createdDate)).toBeTruthy(); expect(await screen.findByText(email.number_sent)).toBeTruthy();
expect(await screen.findByText(email.number_sent)).toBeTruthy(); expect(await screen.findByText(email.requester)).toBeTruthy();
expect(await screen.findByText(email.requester)).toBeTruthy(); expect(await screen.findByText(email.sent_to.join(', '))).toBeTruthy();
expect(await screen.findByText(email.sent_to.join(', '))).toBeTruthy(); expect(await screen.findByText(email.email.subject)).toBeTruthy();
expect(await screen.findByText(email.email.subject)).toBeTruthy(); // verify screen reader only <span />
// verify screen reader only <span /> expect(await screen.findByText('0')).toHaveClass('sr-only');
expect(await screen.findByText('0')).toHaveClass('sr-only'); expect(await screen.findAllByText('View Message')).toBeTruthy();
expect(await screen.findAllByText('View Message')).toBeTruthy(); });
}); });
test('renders a modal that will display the contents of the previously sent message to a user', async () => { test('renders a modal that will display the contents of the previously sent message to a user', async () => {
const emailHistoryData = buildEmailContentHistoryData(1); await act(async () => {
getSentEmailHistory.mockImplementation(() => emailHistoryData); const emailHistoryData = buildEmailContentHistoryData(1);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
render(renderBulkEmailContentHistory()); render(renderBulkEmailContentHistory());
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History'); const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton); fireEvent.click(showEmailContentHistoryButton);
const viewMessageButton = await screen.findByText('View Message'); const viewMessageButton = await screen.findByText('View Message');
fireEvent.click(viewMessageButton); fireEvent.click(viewMessageButton);
// verify modal components and behavior // verify modal components and behavior
const { emails } = emailHistoryData; const { emails } = emailHistoryData;
const email = emails[0]; const email = emails[0];
const closeButton = await screen.findAllByText('Close'); const closeButton = await screen.findAllByText('Close');
expect(closeButton).toBeTruthy(); expect(closeButton).toBeTruthy();
expect(await screen.findByText('Subject:')).toBeTruthy(); expect(await screen.findByText('Subject:')).toBeTruthy();
expect(await screen.findByText('Sent by:')).toBeTruthy(); expect(await screen.findByText('Sent by:')).toBeTruthy();
expect(await screen.findByText('Time sent:')).toBeTruthy(); expect(await screen.findByText('Time sent:')).toBeTruthy();
expect(await screen.findByText('Sent to:')).toBeTruthy(); expect(await screen.findByText('Sent to:')).toBeTruthy();
expect(await screen.findByText('Message:')).toBeTruthy(); expect(await screen.findByText('Message:')).toBeTruthy();
expect(await screen.findAllByText(email.email.subject)).toBeTruthy(); expect(await screen.findAllByText(email.email.subject)).toBeTruthy();
expect(await screen.findAllByText(email.requester)).toBeTruthy(); expect(await screen.findAllByText(email.requester)).toBeTruthy();
const createdDate = new Date(email.created).toLocaleString(); expect(await screen.findAllByText(email.created)).toBeTruthy();
expect(await screen.findAllByText(createdDate)).toBeTruthy(); expect(await screen.findAllByText(email.sent_to.join(', '))).toBeTruthy();
expect(await screen.findAllByText(email.sent_to.join(', '))).toBeTruthy(); // .replace() call strips the HTML tags from the string
// .replace() call strips the HTML tags from the string expect(await screen.findByText(email.email.html_message.replace(/<[^>]*>?/gm, ''))).toBeTruthy();
expect(await screen.findByText(email.email.html_message.replace(/<[^>]*>?/gm, ''))).toBeTruthy(); });
}); });
test('renders a warning Alert when the button is pressed but there is no data to display', async () => { test('renders a warning Alert when the button is pressed but there is no data to display', async () => {
const emailHistoryData = buildEmailContentHistoryData(0); await act(async () => {
getSentEmailHistory.mockImplementation(() => emailHistoryData); const emailHistoryData = buildEmailContentHistoryData(0);
// render the component getSentEmailHistory.mockImplementation(() => emailHistoryData);
render(renderBulkEmailContentHistory()); // render the component
// press the `show sent email history` button to initiate data retrieval render(renderBulkEmailContentHistory());
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History'); // press the `show sent email history` button to initiate data retrieval
fireEvent.click(showEmailContentHistoryButton); const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
// verify that an alert is displayed since the array of tasks is empty fireEvent.click(showEmailContentHistoryButton);
const alertMessage = await screen.findByText('There is no email history for this course.'); // verify that an alert is displayed since the array of tasks is empty
expect(alertMessage).toBeTruthy(); const alertMessage = await screen.findByText('There is no email history for this course.');
expect(alertMessage).toBeTruthy();
});
}); });
test('renders an error Alert when the button is pressed and an error occurs retrieving data', async () => { test('renders an error Alert when the button is pressed and an error occurs retrieving data', async () => {
getSentEmailHistory.mockImplementation(() => { await act(async () => {
throw new Error(); getSentEmailHistory.mockImplementation(() => {
throw new Error();
});
// render the component
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);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText(
'An error occurred retrieving email history data for this course. Please try again later.',
);
expect(alertMessage).toBeTruthy();
}); });
// render the component
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);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText(
'An error occurred retrieving email history data for this course. Please try again later.',
);
expect(alertMessage).toBeTruthy();
}); });
}); });

View File

@@ -61,7 +61,7 @@ describe('BulkEmailPendingTasks component', () => {
expect(await screen.findByText('State')).toBeTruthy(); expect(await screen.findByText('State')).toBeTruthy();
expect(await screen.findByText('Status')).toBeTruthy(); expect(await screen.findByText('Status')).toBeTruthy();
expect(await screen.findByText('Task Progress')).toBeTruthy(); expect(await screen.findByText('Task Progress')).toBeTruthy();
expect(await screen.findAllByText('Showing 1 - 1 of 1.')).toBeTruthy(); expect(await screen.findAllByText('Showing 1 of 1.')).toBeTruthy();
// verification of table contents // verification of table contents
const { tasks } = pendingInstructorTaskData; const { tasks } = pendingInstructorTaskData;

View File

@@ -3,7 +3,7 @@
*/ */
import React from 'react'; import React from 'react';
import { import {
render, screen, fireEvent, cleanup, initializeMockApp, render, screen, fireEvent, cleanup, act, initializeMockApp,
} from '../../../../setupTest'; } from '../../../../setupTest';
import BulkEmailTaskHistory from '../BulkEmailTaskHistory'; import BulkEmailTaskHistory from '../BulkEmailTaskHistory';
import { getEmailTaskHistory } from '../data/api'; import { getEmailTaskHistory } from '../data/api';
@@ -32,66 +32,71 @@ describe('BulkEmailTaskHistory component', () => {
}); });
test('renders a table properly when the button is pressed and data is returned', async () => { test('renders a table properly when the button is pressed and data is returned', async () => {
// build our mocked response await act(async () => {
const taskHistoryData = buildEmailTaskHistoryData(1); // build our mocked response
getEmailTaskHistory.mockImplementation(() => taskHistoryData); const taskHistoryData = buildEmailTaskHistoryData(1);
// render the component getEmailTaskHistory.mockImplementation(() => taskHistoryData);
render(<BulkEmailTaskHistory />); // render the component
// press the `show task history button` to initiate data retrieval and rendering of the table in our component render(<BulkEmailTaskHistory />);
const showTaskHistoryButton = await screen.findByText('Show Email Task History'); // press the `show task history button` to initiate data retrieval and rendering of the table in our component
fireEvent.click(showTaskHistoryButton); const showTaskHistoryButton = await screen.findByText('Show Email Task History');
// verification of table structure fireEvent.click(showTaskHistoryButton);
expect(await screen.findByText('Task Type')).toBeTruthy(); // verification of table structure
expect(await screen.findByText('Task Inputs')).toBeTruthy(); expect(await screen.findByText('Task Type')).toBeTruthy();
expect(await screen.findByText('Task Id')).toBeTruthy(); expect(await screen.findByText('Task Inputs')).toBeTruthy();
expect(await screen.findByText('Requester')).toBeTruthy(); expect(await screen.findByText('Task Id')).toBeTruthy();
expect(await screen.findByText('Submitted')).toBeTruthy(); expect(await screen.findByText('Requester')).toBeTruthy();
expect(await screen.findByText('Duration (seconds)')).toBeTruthy(); expect(await screen.findByText('Submitted')).toBeTruthy();
expect(await screen.findByText('State')).toBeTruthy(); expect(await screen.findByText('Duration (seconds)')).toBeTruthy();
expect(await screen.findByText('Status')).toBeTruthy(); expect(await screen.findByText('State')).toBeTruthy();
expect(await screen.findByText('Task Progress')).toBeTruthy(); expect(await screen.findByText('Status')).toBeTruthy();
expect(await screen.findAllByText('Showing 1 - 1 of 1.')).toBeTruthy(); expect(await screen.findByText('Task Progress')).toBeTruthy();
// verification of row contents expect(await screen.findAllByText('Showing 1 of 1.')).toBeTruthy();
const { tasks } = taskHistoryData; // verification of row contents
const task = tasks[0]; const { tasks } = taskHistoryData;
const createdDate = new Date(task.created).toLocaleString(); const task = tasks[0];
expect(await screen.findByText(createdDate)).toBeTruthy(); expect(await screen.findByText(task.created)).toBeTruthy();
expect(await screen.findByText(task.duration_sec)).toBeTruthy(); expect(await screen.findByText(task.duration_sec)).toBeTruthy();
expect(await screen.findByText(task.requester)).toBeTruthy(); expect(await screen.findByText(task.requester)).toBeTruthy();
expect(await screen.findByText(task.status)).toBeTruthy(); expect(await screen.findByText(task.status)).toBeTruthy();
expect(await screen.findByText(task.task_id)).toBeTruthy(); expect(await screen.findByText(task.task_id)).toBeTruthy();
expect(await screen.findByText(task.task_input)).toBeTruthy(); expect(await screen.findByText(task.task_input)).toBeTruthy();
expect(await screen.findByText(task.task_message)).toBeTruthy(); expect(await screen.findByText(task.task_message)).toBeTruthy();
expect(await screen.findByText(task.task_state)).toBeTruthy(); expect(await screen.findByText(task.task_state)).toBeTruthy();
expect(await screen.findByText(task.task_type)).toBeTruthy(); expect(await screen.findByText(task.task_type)).toBeTruthy();
});
}); });
test('renders a warning Alert when the button is pressed but there is no data to display', async () => { test('renders a warning Alert when the button is pressed but there is no data to display', async () => {
const taskHistoryData = buildEmailTaskHistoryData(0); await act(async () => {
getEmailTaskHistory.mockImplementation(() => taskHistoryData); const taskHistoryData = buildEmailTaskHistoryData(0);
// render the component getEmailTaskHistory.mockImplementation(() => taskHistoryData);
render(<BulkEmailTaskHistory />); // render the component
// press the `show task history` button to initiate data retrieval render(<BulkEmailTaskHistory />);
const showTaskHistoryButton = await screen.findByText('Show Email Task History'); // press the `show task history` button to initiate data retrieval
fireEvent.click(showTaskHistoryButton); const showTaskHistoryButton = await screen.findByText('Show Email Task History');
// verify that an alert is displayed since the array of tasks is empty fireEvent.click(showTaskHistoryButton);
const alertMessage = await screen.findByText('There is no email task history for this course.'); // verify that an alert is displayed since the array of tasks is empty
expect(alertMessage).toBeTruthy(); const alertMessage = await screen.findByText('There is no email task history for this course.');
expect(alertMessage).toBeTruthy();
});
}); });
test('renders an error Alert when the button is pressed and an error occurs retrieving data', async () => { test('renders an error Alert when the button is pressed and an error occurs retrieving data', async () => {
getEmailTaskHistory.mockImplementation(() => { await act(async () => {
throw new Error(); getEmailTaskHistory.mockImplementation(() => {
throw new Error();
});
// render the component
render(<BulkEmailTaskHistory />);
// press the `show task history` button to initiate data retrieval
const showTaskHistoryButton = await screen.findByText('Show Email Task History');
fireEvent.click(showTaskHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText(
'Error fetching email task history data for this course. Please try again later.',
);
expect(alertMessage).toBeTruthy();
}); });
// render the component
render(<BulkEmailTaskHistory />);
// press the `show task history` button to initiate data retrieval
const showTaskHistoryButton = await screen.findByText('Show Email Task History');
fireEvent.click(showTaskHistoryButton);
// verify that an alert is displayed since the array of tasks is empty
const alertMessage = await screen.findByText(
'Error fetching email task history data for this course. Please try again later.',
);
expect(alertMessage).toBeTruthy();
}); });
}); });

View File

@@ -1,13 +1,13 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@openedx/paragon'; import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
function TaskAlertModal(props) { function TaskAlertModal(props) {
const { const {
isOpen, close, alertMessage, isOpen, close, alertMessage, intl,
} = props; } = props;
const intl = useIntl();
const messages = { const messages = {
taskAlertTitle: { taskAlertTitle: {
id: 'bulk.email.task.alert.title', id: 'bulk.email.task.alert.title',
@@ -57,6 +57,7 @@ TaskAlertModal.propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired, close: PropTypes.func.isRequired,
alertMessage: PropTypes.node.isRequired, alertMessage: PropTypes.node.isRequired,
intl: intlShape.isRequired,
}; };
export default TaskAlertModal; export default injectIntl(TaskAlertModal);

View File

@@ -17,8 +17,8 @@ import 'tinymce/plugins/image';
import 'tinymce/plugins/codesample'; import 'tinymce/plugins/codesample';
import '@edx/tinymce-language-selector'; import '@edx/tinymce-language-selector';
import contentUiCss from 'tinymce/skins/ui/oxide/content.css?raw'; import contentUiCss from 'tinymce/skins/ui/oxide/content.css';
import contentCss from 'tinymce/skins/content/default/content.css?raw'; import contentCss from 'tinymce/skins/content/default/content.css';
export default function TextEditor(props) { export default function TextEditor(props) {
const { const {

View File

@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@openedx/paragon'; import { Button, Icon } from '@edx/paragon';
import { ArrowBack } from '@openedx/paragon/icons'; import { ArrowBack } from '@edx/paragon/icons';
export default function BackToInstructor(props) { export default function BackToInstructor(props) {
const { courseId } = props; const { courseId } = props;

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Nav } from '@openedx/paragon'; import { Nav } from '@edx/paragon';
export default function NavigationTabs(props) { export default function NavigationTabs(props) {
const { tabData } = props; const { tabData } = props;

View File

@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { LearningHeader as Header } from '@edx/frontend-component-header'; import { LearningHeader as Header } from '@edx/frontend-component-header';
import { FooterSlot } from '@edx/frontend-component-footer'; import Footer from '@edx/frontend-component-footer';
import { Spinner } from '@openedx/paragon'; import { Spinner } from '@edx/paragon';
import { getCohorts, getCourseHomeCourseMetadata } from './data/api'; import { getCohorts, getCourseHomeCourseMetadata } from './data/api';
@@ -39,7 +39,7 @@ export default function PageContainer(props) {
} }
const { const {
org, number, title, tabs, originalUserIsStaff, courseModes, org, number, title, tabs, originalUserIsStaff,
} = metadataResponse; } = metadataResponse;
const { cohorts } = cohortsResponse; const { cohorts } = cohortsResponse;
@@ -48,7 +48,6 @@ export default function PageContainer(props) {
number, number,
title, title,
originalUserIsStaff, originalUserIsStaff,
courseModes,
tabs: [...tabs], tabs: [...tabs],
cohorts: cohorts.map(({ name }) => name), cohorts: cohorts.map(({ name }) => name),
}); });
@@ -67,12 +66,10 @@ export default function PageContainer(props) {
courseNumber={courseMetadata.number} courseNumber={courseMetadata.number}
courseTitle={courseMetadata.title} courseTitle={courseMetadata.title}
/> />
<div className="pb-3 container"> <main>
<main id="main-content"> {children}
{children} </main>
</main> <Footer />
</div>
<FooterSlot />
</> </>
</CourseMetadataContext.Provider> </CourseMetadataContext.Provider>
); );

View File

@@ -4,7 +4,7 @@
import React from 'react'; import React from 'react';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { import {
cleanup, initializeMockApp, render, screen, act, cleanup, initializeMockApp, render, screen,
} from '../../../setupTest'; } from '../../../setupTest';
import PageContainer from '../PageContainer'; import PageContainer from '../PageContainer';
@@ -32,32 +32,36 @@ describe('PageContainer', () => {
afterEach(cleanup); afterEach(cleanup);
test('PageContainer renders properly when given course metadata', async () => { test('PageContainer renders properly when given course metadata', async () => {
const cohorts = { cohorts: [Factory.build('cohort'), Factory.build('cohort')] }; await act(async () => {
const courseMetadata = Factory.build('courseMetadata'); const cohorts = { cohorts: [Factory.build('cohort'), Factory.build('cohort')] };
const courseMetadata = Factory.build('courseMetadata');
getCohorts.mockImplementation(() => cohorts); getCohorts.mockImplementation(() => cohorts);
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata); getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
render(<PageContainer />); render(<PageContainer />);
// Look for the org, title, and number of the course, which should be displayed in the Header. // Look for the org, title, and number of the course, which should be displayed in the Header.
expect(await screen.findByText(`${courseMetadata.org} ${courseMetadata.number}`)).toBeTruthy(); expect(await screen.findByText(`${courseMetadata.org} ${courseMetadata.number}`)).toBeTruthy();
expect(await screen.findByText(courseMetadata.title)).toBeTruthy(); expect(await screen.findByText(courseMetadata.title)).toBeTruthy();
});
}); });
test('PageContainer renders children nested within it.', async () => { test('PageContainer renders children nested within it.', async () => {
const cohorts = { cohorts: [Factory.build('cohort'), Factory.build('cohort')] }; await act(async () => {
const courseMetadata = Factory.build('courseMetadata'); const cohorts = { cohorts: [Factory.build('cohort'), Factory.build('cohort')] };
const courseMetadata = Factory.build('courseMetadata');
getCohorts.mockImplementation(() => cohorts); getCohorts.mockImplementation(() => cohorts);
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata); getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
render( render(
<PageContainer> <PageContainer>
<span>Test Text</span> <span>Test Text</span>
</PageContainer>, </PageContainer>,
); );
expect(await screen.findByText('Test Text')).toBeTruthy(); expect(await screen.findByText('Test Text')).toBeTruthy();
});
}); });
}); });

View File

@@ -1 +0,0 @@
export default [];

32
src/i18n/index.jsx Normal file
View File

@@ -0,0 +1,32 @@
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;

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -5,51 +5,41 @@ import {
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, getConfig, APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, getConfig,
} from '@edx/frontend-platform'; } from '@edx/frontend-platform';
import { AppProvider, AuthenticatedPageRoute, ErrorPage } from '@edx/frontend-platform/react'; import { AppProvider, AuthenticatedPageRoute, ErrorPage } from '@edx/frontend-platform/react';
import { StrictMode } from 'react'; import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import { messages as headerMessages } from '@edx/frontend-component-header';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as paragonMessages } from '@edx/paragon';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Routes, Route } from 'react-router-dom'; import { Switch } from 'react-router-dom';
import messages from './i18n'; import appMessages from './i18n';
import './index.scss'; import './index.scss';
import BulkEmailTool from './components/bulk-email-tool'; import BulkEmailTool from './components/bulk-email-tool';
import PageContainer from './components/page-container/PageContainer'; import PageContainer from './components/page-container/PageContainer';
subscribe(APP_READY, () => { subscribe(APP_READY, () => {
const root = createRoot(document.getElementById('root')); ReactDOM.render(
<AppProvider>
root.render( <Helmet>
<StrictMode> <link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
<AppProvider> </Helmet>
<Helmet> <div className="pb-3 container">
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" /> <Switch>
</Helmet> <AuthenticatedPageRoute path="/courses/:courseId/bulk_email">
<Routes> <PageContainer>
<Route <BulkEmailTool />
path="/courses/:courseId/bulk_email" </PageContainer>
element={( </AuthenticatedPageRoute>
<AuthenticatedPageRoute> </Switch>
<PageContainer> </div>
<BulkEmailTool /> </AppProvider>,
</PageContainer> document.getElementById('root'),
</AuthenticatedPageRoute>
)}
/>
</Routes>
</AppProvider>
</StrictMode>,
); );
}); });
subscribe(APP_INIT_ERROR, (error) => { subscribe(APP_INIT_ERROR, (error) => {
const root = createRoot(document.getElementById('root')); ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
root.render(
<StrictMode>
<ErrorPage message={error.message} />
</StrictMode>,
);
}); });
initialize({ initialize({
@@ -64,5 +54,5 @@ initialize({
); );
}, },
}, },
messages, messages: [appMessages, headerMessages, footerMessages, paragonMessages],
}); });

View File

@@ -1,4 +1,7 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints; @import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-header/dist/index"; @import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer"; @import "~@edx/frontend-component-footer/dist/footer";

View File

@@ -1,67 +0,0 @@
import {
APP_INIT_ERROR, APP_READY, subscribe,
} from '@edx/frontend-platform';
// Jest needs this for module resolution
import * as app from '.'; // eslint-disable-line @typescript-eslint/no-unused-vars
// These need to be var not let so they get hoisted
// and can be used by jest.mock (which is also hoisted)
var mockRender; // eslint-disable-line no-var
var mockCreateRoot; // eslint-disable-line no-var
jest.mock('react-dom/client', () => {
mockRender = jest.fn();
mockCreateRoot = jest.fn(() => ({
render: mockRender,
}));
return ({
createRoot: mockCreateRoot,
});
});
jest.mock('@edx/frontend-platform', () => ({
APP_READY: 'app-is-ready-key',
APP_INIT_ERROR: 'app-init-error',
subscribe: jest.fn(),
initialize: jest.fn(),
mergeConfig: jest.fn(),
getConfig: () => ({
FAVICON_URL: 'favicon-url',
}),
ensureConfig: jest.fn(),
}));
jest.mock('./components/bulk-email-tool/BulkEmailTool', () => 'Bulk Email Tool');
jest.mock('./components/page-container/PageContainer', () => 'Page Container');
describe('app registry', () => {
let getElement;
beforeEach(() => {
mockCreateRoot.mockClear();
mockRender.mockClear();
getElement = window.document.getElementById;
window.document.getElementById = jest.fn(id => ({ id }));
});
afterAll(() => {
window.document.getElementById = getElement;
});
test('subscribe: APP_READY. links App to root element', () => {
const callArgs = subscribe.mock.calls[0];
expect(callArgs[0]).toEqual(APP_READY);
callArgs[1]();
const [rendered] = mockRender.mock.calls[0];
expect(rendered).toMatchSnapshot();
});
test('subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element', () => {
const callArgs = subscribe.mock.calls[1];
expect(callArgs[0]).toEqual(APP_INIT_ERROR);
const error = { message: 'test-error-message' };
callArgs[1](error);
const [rendered] = mockRender.mock.calls[0];
expect(rendered).toMatchSnapshot();
});
});

View File

@@ -1,53 +0,0 @@
# Footer Slot
### Slot ID: `org.openedx.frontend.layout.footer.v1`
### Slot ID Aliases
* `footer_slot`
## Description
This slot is used to replace/modify/hide the footer.
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
## Example
The following `env.config.jsx` will replace the default footer.
![Screenshot of Default Footer](./images/default_footer.png)
with a simple custom footer
![Screenshot of Custom Footer](./images/custom_footer.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
'org.openedx.frontend.layout.footer.v1': {
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;
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,3 +0,0 @@
# `frontend-app-communications` Plugin Slots
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)

View File

@@ -8,14 +8,12 @@ import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform
import { configure as configureLogging, MockLoggingService } from '@edx/frontend-platform/logging'; import { configure as configureLogging, MockLoggingService } from '@edx/frontend-platform/logging';
import { getConfig, mergeConfig } from '@edx/frontend-platform'; import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { configure as configureAuth, MockAuthService } from '@edx/frontend-platform/auth'; import { configure as configureAuth, MockAuthService } from '@edx/frontend-platform/auth';
import messages from './i18n'; import appMessages from './i18n';
jest.mock('@edx/frontend-platform/react/hooks', () => ({ jest.mock('@edx/frontend-platform/react/hooks', () => ({
...jest.requireActual('@edx/frontend-platform/react/hooks'), ...jest.requireActual('@edx/frontend-platform/react/hooks'),
useTrackColorSchemeChoice: jest.fn(), useTrackColorSchemeChoice: jest.fn(),
useParagonTheme: () => [{ isThemeLoaded: true }, jest.fn()],
})); }));
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: jest.fn().mockImplementation((query) => ({ value: jest.fn().mockImplementation((query) => ({
@@ -30,8 +28,6 @@ Object.defineProperty(window, 'matchMedia', {
})), })),
}); });
global.Date.prototype.toLocaleDateString = jest.fn();
export function initializeMockApp() { export function initializeMockApp() {
mergeConfig({ mergeConfig({
// MICROBA-1505: Remove this when we remove the flag from config // MICROBA-1505: Remove this when we remove the flag from config
@@ -51,7 +47,7 @@ export function initializeMockApp() {
const i18nService = configureI18n({ const i18nService = configureI18n({
config: getConfig(), config: getConfig(),
loggingService, loggingService,
messages, messages: [appMessages],
}); });
const authService = configureAuth(MockAuthService, { config: getConfig(), loggingService }); const authService = configureAuth(MockAuthService, { config: getConfig(), loggingService });

View File

@@ -1,13 +0,0 @@
{
"extends": "@edx/typescript-config",
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"*": ["*"]
},
"rootDir": ".",
"outDir": "dist"
},
"include": ["*.js", ".eslintrc.js", "src/**/*", "plugins/**/*", "jest.config.ts"],
"exclude": ["*.js", ".eslintrc.js", "dist", "node_modules"]
}

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@openedx/frontend-build'); const { createConfig } = require('@edx/frontend-build');
const config = createConfig('webpack-dev'); const config = createConfig('webpack-dev');
@@ -22,13 +22,8 @@ const webpack5esmInteropRule = {
}, },
}; };
const rawAssetRule = {
resourceQuery: /raw/,
type: 'asset/source',
};
const otherRules = config.module.rules; const otherRules = config.module.rules;
config.module.rules = [rawAssetRule, webpack5esmInteropRule, ...otherRules]; config.module.rules = [webpack5esmInteropRule, ...otherRules];
module.exports = config; module.exports = config;

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@openedx/frontend-build'); const { createConfig } = require('@edx/frontend-build');
const config = createConfig('webpack-prod'); const config = createConfig('webpack-prod');
@@ -14,13 +14,8 @@ const webpack5esmInteropRule = {
}, },
}; };
const rawAssetRule = {
resourceQuery: /raw/,
type: 'asset/source',
};
const otherRules = config.module.rules; const otherRules = config.module.rules;
config.module.rules = [rawAssetRule, webpack5esmInteropRule, ...otherRules]; config.module.rules = [webpack5esmInteropRule, ...otherRules];
module.exports = config; module.exports = config;