Compare commits

...

63 Commits

Author SHA1 Message Date
edX requirements bot
e927c8e358 chore: enable github action auto update in dependabot.yml 2024-09-05 06:49:57 -04:00
Muhammad Anas
ef6ade6de1 test: Add Node 20 to CI matrix (#210) 2024-09-03 14:31:42 -04:00
Braden MacDonald
7dc08c060f chore: remove reference to deprecated frontend-lib-content-components repo (#209) 2024-09-03 10:00:30 -07:00
Bilal Qamar
1e1f269b6b feat: updated frontend-build & frontend-platform major versions (#181)
* chore: bumped jest to v29

* refactor: updated frontend-build & added overrides

* feat: updated build and platform major versions, along with edx packages

* refactor: added caret to frontend-platform version
2024-08-06 15:36:25 +05:00
Emad Rad
a8093439a9 fix: update README.rst (#208)
Ffix typos, add code-blocks and highlighting for better readability
2024-07-24 11:51:29 -07:00
Adolfo R. Brandes
10f906b72f build: Update codecov and use token
Update codecov to the latest version and start using the org-wide token for uploads.

See https://github.com/openedx/wg-frontend/issues/179
2024-06-17 12:02:35 -03:00
Brian Smith
3a14119d01 fix: import FooterSlot from frontend-slot-footer package 2024-05-17 09:37:27 -03:00
Brian Smith
cb3b2b4670 feat: use frontend-plugin-framework to provide a FooterSlot 2024-05-09 16:54:37 -03:00
sundasnoreen12
9279a3e4ce Merge pull request #200 from openedx/sundas/INF-1278
feat: remove Transifex calls for OEP-58 for communication app
2024-03-22 14:15:34 +05:00
sundasnoreen12
2a5cf010f8 feat: remove Transifex calls for OEP-58 for communication app 2024-03-21 23:23:59 +05:00
eemaanamir
6dd835d63f refactor: converted functions to memos for best practice 2024-03-06 09:15:44 -03:00
eemaanamir
87c1cb5bd2 test: updated tests for both components to accommodate changes 2024-03-06 09:15:44 -03:00
eemaanamir
5875631a6c refactor: updated code according to best practices 2024-03-06 09:15:44 -03:00
eemaanamir
176a95c352 fix: converted UTC time to localtime in all the bulk email history tables 2024-03-06 09:15:44 -03:00
Kyle McCormick
b24971572d build: update catalog-info team frontend-all to committers-frontend
per https://openedx.atlassian.net/wiki/spaces/COMM/pages/3555852316/GitHub+Access+Team+Structure
2024-03-01 13:00:31 -03:00
Taras Lytvynenko
20f212501f fix: Added maxLength and default tip for email subject (#154)
* fix: Added maxLength and default tip for email subject

* fix: lint
2024-02-27 15:14:56 -03:00
Eugene Dyudyunov
852d0ef2d9 refactor: apply review suggestions 2024-02-27 15:00:14 -03:00
Taras Lytvynenko
809112d4b2 docs: Unnecessary comment deleted 2024-02-27 15:00:14 -03:00
Taras Lytvynenko
d249b5e4aa fix: Logical operations moved into a separate variable 2024-02-27 15:00:14 -03:00
Taras Lytvynenko
dad6c84d46 fix: Course mode is used to show the correct bulk email options 2024-02-27 15:00:14 -03:00
Brian Smith
5f61578e28 chore(deps): update paragon and frontend-build to openedx scope (#190) 2024-01-22 17:23:16 -05:00
Omar Al-Ithawi
da30730662 chore: bump openedx-atlas==0.6.0 (#189) 2024-01-17 11:50:29 -05:00
Omar Al-Ithawi
6e4ae9d976 feat: add ATLAS_OPTIONS and lib-components and frontend-platform deps (#187) 2024-01-09 11:51:56 -05:00
arbrandes
e863eef77d chore: update browserslist DB 2023-12-15 14:57:36 -03:00
arbrandes
ff900335a1 chore: update browserslist DB 2023-12-04 11:23:40 -03:00
arbrandes
c0dc0cbad6 chore: update browserslist DB 2023-11-28 11:42:45 -03:00
arbrandes
83aec7318e chore: update browserslist DB 2023-11-20 12:31:27 -03:00
Ihor Romaniuk
a037f5b340 fix: date format depends on locale date format (#175) 2023-11-16 07:05:57 -03:00
Omar Al-Ithawi
91402fbf12 feat!: remove broken transifex and use atlas exclusively | FC-0012 (#164)
* feat!: remove broken transifex and use atlas exclusively

* feat: install openedx-atlas
2023-11-15 15:10:50 -05:00
o.bugaenko
7a299eb064 fix: wrong id placement on the h1 tag 2023-11-14 16:13:56 -03:00
arbrandes
90e8eeeb50 chore: update browserslist DB 2023-11-14 13:04:20 -03:00
vladislavkeblysh
f25ce0b95d feat: fixed layout 2023-11-14 12:32:37 -03:00
Stanislav Lunyachek
98db1a4a35 fix: Missed favicon in Safari 2023-11-14 12:23:58 -03:00
Muhammad Abdullah Waheed
cf4b632c55 feat: babel-plugin-react-intl to babel-plugin-formatjs migration (#151)
* feat: babel-plugin-react-intl to babel-plugin-formatjs migration

* fix: upgraded frontend-build to fix security issue
2023-11-14 12:58:07 +05:00
Mashal Malik
0720b2feae refactor: add @openedx in renovate automate configuration (#150) 2023-11-01 12:38:32 -03:00
Jason Wesson
56e1781004 Merge pull request #145 from openedx/update-browserslist-db
Update browserslist DB
2023-10-23 13:07:18 -07:00
arbrandes
83eb21bb9a chore: update browserslist DB 2023-10-23 00:07:35 +00:00
Feanil Patel
18ddb35e1e chore: Update to the new version of brand-openedx in the new scope. (#165)
Part of https://github.com/openedx/axim-engineering/issues/23

This updates the `@edx/brand` alias to point to the `brand-openedx` package at
the `openedx` scope. This does not impact imports because this package is used
via an alias.
2023-10-20 17:26:49 -04:00
Syed Ali Abbas Zaidi
ae93b68d57 chore: bump frontend-platform (#158) 2023-10-12 18:41:58 +05:00
mashal-m
7ce2a61233 refactor: update lock file version 2023-09-22 10:49:43 -03:00
Awais Ansari
2de12997ce Merge pull request #122 from openedx/Ali-Abbas/react-router-upgrade
feat: upgrade react router to v6
2023-09-05 18:10:45 +05:00
Syed Ali Abbas Zaidi
f6c8cfa906 build: update header and footer 2023-08-21 17:22:24 +05:00
Syed Ali Abbas Zaidi
db181a7945 Merge branch 'master' of github.com:openedx/frontend-app-communications into Ali-Abbas/react-router-upgrade 2023-08-08 16:38:54 +05:00
arbrandes
840449f71b chore: update browserslist DB 2023-07-31 16:38:23 +01:00
Adolfo R. Brandes
01f1793414 chore: add catalog-info.yaml 2023-07-31 16:36:18 +01:00
Jhon Vente
ce45c8012d [DOCS] Readme updated according OEP-55 (#141)
* docs: readme updated with frontend-template-app readme ref
* fix: change main branch to master, spelling and getting help url
2023-07-07 08:49:27 -04:00
edX requirements bot
7224785682 chore: update browserslist DB (#142)
Co-authored-by: justinhynes <justinhynes@users.noreply.github.com>
2023-07-06 13:38:59 -04:00
Bilal Qamar
6d27b7082d feat: update react & react-dom to v17 (#131)
* feat: update react & react-dom to v17
* build: update paragon version
* refactor: updated edx packages
---------
Co-authored-by: mashal-m <mashal.malik@arbisoft.com>
2023-07-06 12:28:41 -04:00
edX requirements bot
c90bdb1ac7 chore: update browserslist DB (#132)
Co-authored-by: arbrandes <arbrandes@users.noreply.github.com>
2023-06-21 13:09:55 -04:00
Adolfo R. Brandes
090968e2f2 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:43 +01:00
Tobias Macey
765857f380 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-12 19:18:12 +01:00
edX requirements bot
0eec29a458 chore: update browserslist DB (#128)
Co-authored-by: arbrandes <arbrandes@users.noreply.github.com>
2023-06-05 15:06:12 -04:00
Mashal Malik
c2b4d04b5d Migrate off paragon modal deprecated component (#95)
* refactor: migrate off paragon modal deprecated component

* refactor: migrate off paragon modal deprecated component

* refactor: resource the close button string

* refactor: remove extra file

* refactor: add message in messages  file
2023-06-05 14:47:53 -04:00
Ghassan Maslamani
a198557e67 fix: getting course-id when public path is set (#127) 2023-06-01 14:30:35 -03:00
Muhammad Abdullah Waheed
4493eb71fa feat: automate browserlist DB update (#92) 2023-05-23 10:08:33 -03:00
Adolfo R. Brandes
559a1061b6 Merge pull request #125 from arbrandes/runtime-config 2023-05-23 08:54:59 -03:00
Adolfo R. Brandes
53fa594207 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-05-22 10:00:22 -03:00
Mashal Malik
0c34d86610 feat: upgrade to node v18 and related fixes (#123) 2023-05-17 14:19:00 -03:00
Syed Ali Abbas Zaidi
05d16cf231 Merge branch 'master' of github.com:openedx/frontend-app-communications into Ali-Abbas/react-router-upgrade 2023-05-16 16:13:15 +05:00
Omar Al-Ithawi
55a0ddb8d2 feat: use atlas in make pull_translations (#124)
Changes
-------
 - Bump frontend-platform to bring intl-imports.js script
 - Move all i18n imports into `src/i18n/index.js` so intl-imports.js can
   override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL`
   environment variable is set.
 - Fixed lint rules for frontend-platform@4.1.0
 - Mock useTrackColorSchemeChoice to avoid test failures

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-05-09 10:08:36 -04:00
Mashal Malik
344b68e10e Update transifex api from v2 to v3 (#119)
* fix: fix conflicts

* refactor: remove duplicate line

* fix: update lock file
2023-03-24 12:48:12 +05:00
Mashal Malik
631d47b286 fix: remove unused codecov pkg (#120) 2023-03-24 12:45:37 +05:00
Syed Ali Abbas Zaidi
81a5f89f36 feat: upgrade react router to v6 2023-03-21 14:26:08 +05:00
76 changed files with 8578 additions and 38424 deletions

2
.env
View File

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

View File

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

View File

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

View File

@@ -1,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',
},
});

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -11,14 +11,17 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
node: [18, 20]
continue-on-error: ${{ matrix.node == 20 }}
steps:
- name: Checkout
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@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
@@ -34,4 +37,7 @@ jobs:
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true

View File

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

View 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
View File

@@ -17,3 +17,4 @@ temp/babel-plugin-react-intl
*~
/temp
/.vscode
src/i18n/messages/

2
.nvmrc
View File

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

View File

@@ -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 -t -f --mode reviewed --languages=$(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:

View File

@@ -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
View 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"

View File

@@ -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.

View File

@@ -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' },
],
};

46045
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"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",
@@ -33,17 +33,19 @@
"url": "https://github.com/edx/frontend-app-communications/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.6.0",
"@edx/frontend-component-header": "3.5.0",
"@edx/frontend-platform": "2.6.2",
"@edx/paragon": "^20.20.0",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-header": "5.3.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.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",
@@ -51,27 +53,27 @@
"jquery": "3.6.1",
"popper.js": "1.16.1",
"prop-types": "15.8.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-redux": "7.2.9",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"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/browserslist-config": "^1.1.0",
"@edx/frontend-build": "11.0.2",
"@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",
"codecov": "3.8.3",
"glob": "7.2.3",
"husky": "7.0.4",
"jest": "27.5.1",
"jest": "29.7.0",
"prettier": "2.8.1",
"reactifex": "1.1.1",
"rosie": "2.1.0"
}
}

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React from 'react';
import PropTypes from 'prop-types';
import useAsyncReducer, { combineReducers } from '../../../utils/useAsyncReducer';

View File

@@ -1,12 +1,13 @@
/* eslint-disable react/no-unstable-nested-components */
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Button,
Form, Icon, StatefulButton, Toast, useToggle,
} from '@edx/paragon';
} from '@openedx/paragon';
import {
SpinnerSimple, Cancel, Send, Event, Check,
} from '@edx/paragon/icons';
} from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import { getConfig } from '@edx/frontend-platform';
@@ -46,7 +47,12 @@ const FORM_ACTIONS = {
};
function BulkEmailForm(props) {
const { courseId, cohorts, intl } = props;
const {
courseId,
cohorts,
courseModes,
intl,
} = props;
const [{ editor }, dispatch] = useContext(BulkEmailContext);
const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT);
const [emailFormValidation, setEmailFormValidation] = useState({
@@ -271,10 +277,14 @@ function BulkEmailForm(props) {
handleCheckboxes={onRecipientChange}
additionalCohorts={cohorts}
isValid={emailFormValidation.recipients}
courseModes={courseModes}
/>
<Form.Group controlId="emailSubject">
<Form.Label className="h3 text-primary-500">{intl.formatMessage(messages.bulkEmailSubjectLabel)}</Form.Label>
<Form.Control name="emailSubject" className="w-lg-50" onChange={onFormChange} value={editor.emailSubject} />
<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">
{intl.formatMessage(messages.bulkEmailFormSubjectError)}
@@ -383,6 +393,12 @@ BulkEmailForm.propTypes = {
courseId: PropTypes.string.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.string),
intl: intlShape.isRequired,
courseModes: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}),
).isRequired,
};
export default injectIntl(BulkEmailForm);

View File

@@ -2,13 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
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')}>
@@ -30,7 +32,10 @@ function ScheduleEmailForm(props) {
<small className="text-gray-500 x-small">
<FormattedMessage
id="bulk.email.form.schedule.date.description"
defaultMessage="Enter a start date, e.g. 11/27/2023"
defaultMessage="Enter a start date, e.g. {date}"
values={{
date: descriptionDate.toLocaleDateString(),
}}
/>
</small>
</div>
@@ -52,7 +57,10 @@ function ScheduleEmailForm(props) {
<small className="text-gray-500 x-small">
<FormattedMessage
id="bulk.email.form.schedule.time.description"
defaultMessage="Enter a start time, e.g. 09:00 AM"
defaultMessage="Enter a start time, e.g. {time}"
values={{
time: descriptionDate.toLocaleTimeString([], { timeStyle: 'short' }),
}}
/>
</small>
</div>

View File

@@ -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,7 +14,13 @@ 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>
@@ -50,18 +56,24 @@ export default function BulkEmailRecipient(props) {
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)}
className="col col-lg-4 col-sm-6 col-12"
>
<FormattedMessage
id="bulk.email.form.recipients.verified"
defaultMessage="Learners in the verified certificate track"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
{
// additional 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
@@ -80,18 +92,6 @@ export default function BulkEmailRecipient(props) {
</Form.Checkbox>
))
}
<Form.Checkbox
key="track:audit"
value="track:audit"
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
className="col col-lg-4 col-sm-6 col-12"
>
<FormattedMessage
id="bulk.email.form.recipients.audit"
defaultMessage="Learners in the audit track"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox
key="learners"
value="learners"
@@ -127,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,
};

View File

@@ -1 +1,2 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './BulkEmailRecipient';

View File

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

View File

@@ -1 +1,2 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './BulkEmailForm';

View File

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

View File

@@ -12,6 +12,7 @@ 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');
@@ -20,12 +21,17 @@ 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} />
<BulkEmailForm
courseId="test"
cohorts={cohorts}
courseModes={courseMode}
/>
</BulkEmailProvider>
);
}
@@ -33,7 +39,7 @@ function renderBulkEmailForm() {
function renderBulkEmailFormContext(value) {
return (
<BulkEmailContext.Provider value={[value, dispatchMock]}>
<BulkEmailForm courseId="test" />
<BulkEmailForm courseId="test" courseMode={courseMode} />
</BulkEmailContext.Provider>
);
}
@@ -96,8 +102,8 @@ describe('bulk-email-form', () => {
test('Checking "All Learners" disables each learner group', async () => {
render(renderBulkEmailForm());
fireEvent.click(screen.getByRole('checkbox', { name: 'All Learners' }));
const verifiedLearners = screen.getByRole('checkbox', { name: 'Learners in the verified certificate track' });
const auditLearners = screen.getByRole('checkbox', { name: 'Learners in the audit track' });
const verifiedLearners = screen.getByRole('checkbox', { name: 'Learners in the Verified Certificate Track' });
const auditLearners = screen.getByRole('checkbox', { name: 'Learners in the Audit Track' });
const { cohorts } = cohortFactory.build();
cohorts.forEach(cohort => expect(screen.getByRole('checkbox', { name: `Cohort: ${cohort}` })).toBeDisabled());
expect(verifiedLearners).toBeDisabled();

View File

@@ -1,12 +1,14 @@
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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Collapsible, Icon,
} from '@edx/paragon';
import { SpinnerSimple } from '@edx/paragon/icons';
} from '@openedx/paragon';
import { SpinnerSimple } from '@openedx/paragon/icons';
import messages from './messages';
import { getSentEmailHistory } from './data/api';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
@@ -49,17 +51,15 @@ function BulkEmailContentHistory({ intl }) {
* 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
@@ -100,7 +100,7 @@ function BulkEmailContentHistory({ intl }) {
* contents of a previously sent message.
*/
const additionalColumns = () => {
const tableData = transformDataForTable();
const tableData = transformDataForTable;
return [
{
@@ -131,12 +131,13 @@ function BulkEmailContentHistory({ intl }) {
styling="card"
title={intl.formatMessage(messages.emailHistoryTableSectionButton)}
className="mb-3"
// eslint-disable-next-line react/jsx-no-bind
onOpen={fetchSentEmailHistoryData}
>
{showHistoricalEmailContentTable ? (
<BulkEmailTaskManagerTable
errorRetrievingData={errorRetrievingData}
tableData={transformDataForTable()}
tableData={transformDataForTable}
tableDescription={intl.formatMessage(messages.emailHistoryTableViewMessageInstructions)}
alertWarningMessage={intl.formatMessage(messages.noEmailData)}
alertErrorMessage={intl.formatMessage(messages.errorFetchingEmailHistoryData)}

View File

@@ -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 = {

View File

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

View File

@@ -1,9 +1,9 @@
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, Collapsible } 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';
@@ -41,6 +41,14 @@ function BulkEmailTaskHistory({ intl }) {
setShowHistoricalTaskContentTable(true);
}
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)}`,
@@ -89,12 +97,13 @@ function BulkEmailTaskHistory({ intl }) {
<Collapsible
styling="card"
title={intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}
// eslint-disable-next-line react/jsx-no-bind
onOpen={fetchEmailTaskHistoryData}
>
{showHistoricalTaskContentTable ? (
<BulkEmailTaskManagerTable
errorRetrievingData={errorRetrievingData}
tableData={emailTaskHistoryData}
tableData={transformDataForTable}
alertWarningMessage={intl.formatMessage(messages.noTaskHistoryData)}
alertErrorMessage={intl.formatMessage(messages.errorFetchingTaskHistoryData)}
columns={tableColumns}

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Button, Modal } from '@edx/paragon';
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';
@@ -12,10 +12,13 @@ function ViewEmailModal({
const [, dispatch] = useContext(BulkEmailContext);
return (
<div>
<Modal
open={isOpen}
title=""
body={(
<ModalDialog
isOpen={isOpen}
onClose={() => setModalOpen(false)}
hasCloseButton
>
<ModalDialog.Body>
<div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageSubject)}</p>
@@ -40,24 +43,30 @@ function ViewEmailModal({
<div dangerouslySetInnerHTML={{ __html: messageContent.email.html_message }} />
</div>
</div>
)}
onClose={() => setModalOpen(false)}
buttons={[
<Button
onClick={() => {
dispatch(
copyToEditor({
emailBody: messageContent.email.html_message,
emailSubject: messageContent.subject,
}),
);
setModalOpen(false);
}}
>
<FormattedMessage id="bulk.email.tool.copy.message.button" defaultMessage="Copy to editor" />
</Button>,
]}
/>
</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>
);
}

View File

@@ -1,14 +1,16 @@
/* 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 '@edx/paragon';
} from '@openedx/paragon';
import {
Delete, Info, Visibility, Edit,
} from '@edx/paragon/icons';
} from '@openedx/paragon/icons';
import { useParams } from 'react-router-dom';
import { BulkEmailContext } from '../../bulk-email-context';
import { deleteScheduledEmailThunk, getScheduledBulkEmailThunk } from './data/thunks';
@@ -24,6 +26,7 @@ function flattenScheduledEmailsArray(emails) {
emailId: email.courseEmail.id,
task: email.task,
taskDue: new Date(email.taskDue).toLocaleString(),
taskDueUTC: email.taskDue,
...email.courseEmail,
targets: email.courseEmail.targets.join(', '),
}));
@@ -89,10 +92,10 @@ function BulkEmailScheduledEmailsTable({ intl }) {
const handleEditEmail = (row) => {
const {
original: {
htmlMessage: emailBody, subject: emailSubject, taskDue, targets, schedulingId, emailId,
htmlMessage: emailBody, subject: emailSubject, taskDueUTC, targets, schedulingId, emailId,
},
} = row;
const dateTime = new Date(taskDue);
const dateTime = new Date(taskDueUTC);
const emailRecipients = targets.replaceAll('-', ':').split(', ');
const scheduleDate = formatDate(dateTime);
const scheduleTime = formatTime(dateTime);

View File

@@ -1 +1,2 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './BulkEmailScheduledEmailsTable';

View File

@@ -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.',

View File

@@ -66,7 +66,8 @@ 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();
@@ -103,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();

View File

@@ -61,7 +61,7 @@ describe('BulkEmailPendingTasks 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 table contents
const { tasks } = pendingInstructorTaskData;

View File

@@ -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');
});
});

View File

@@ -51,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();

View File

@@ -1 +1,2 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './BulkEmailTool';

View File

@@ -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) {
@@ -40,6 +40,7 @@ function TaskAlertModal(props) {
// 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>

View File

@@ -1 +1,2 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './TaskAlertModal';

View File

@@ -43,6 +43,8 @@ export default function TextEditor(props) {
block_unsupported_drop: false,
image_advtab: true,
name: 'emailBody',
relative_urls: false,
remove_script_host: false,
}}
onEditorChange={onChange}
value={value}

View File

@@ -1 +1,2 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './TextEditor';

View File

@@ -1,16 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import { Button, Icon } from '@openedx/paragon';
import { ArrowBack } from '@openedx/paragon/icons';
export default function BackToInstructor(props) {
const { courseId } = props;
export default function BackToInstructor() {
return (
<Button
variant="tertiary"
className="mb-4.5 ml-n4.5 text-primary-500"
href={`${getConfig().LMS_BASE_URL}/courses/${window.location.pathname.split('/')[2]}/instructor#view-course-info`}
href={`${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor#view-course-info`}
>
<Icon
src={ArrowBack}
@@ -24,3 +27,7 @@ export default function BackToInstructor() {
</Button>
);
}
BackToInstructor.propTypes = {
courseId: PropTypes.string.isRequired,
};

View 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');
});
});

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
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 camelCaseObject(data);
}

View 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));
});
});

1
src/i18n/index.js Normal file
View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 { messages as paragonMessages } from '@edx/paragon';
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'),
);
@@ -50,5 +54,5 @@ initialize({
);
},
},
messages: [appMessages, headerMessages, footerMessages, paragonMessages],
messages,
});

View File

@@ -1,6 +1,6 @@
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-header/dist/index";

View 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.
![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: {
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;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,3 @@
# `frontend-app-communications` Plugin Slots
* [`footer_slot`](./FooterSlot/)

View File

@@ -8,7 +8,12 @@ import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform
import { configure as configureLogging, MockLoggingService } from '@edx/frontend-platform/logging';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { configure as configureAuth, MockAuthService } from '@edx/frontend-platform/auth';
import appMessages from './i18n';
import messages from './i18n';
jest.mock('@edx/frontend-platform/react/hooks', () => ({
...jest.requireActual('@edx/frontend-platform/react/hooks'),
useTrackColorSchemeChoice: jest.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -24,6 +29,8 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
global.Date.prototype.toLocaleDateString = jest.fn();
export function initializeMockApp() {
mergeConfig({
// MICROBA-1505: Remove this when we remove the flag from config
@@ -43,13 +50,18 @@ export function initializeMockApp() {
const i18nService = configureI18n({
config: getConfig(),
loggingService,
messages: [appMessages],
messages,
});
const authService = configureAuth(MockAuthService, { config: getConfig(), loggingService });
return { loggingService, i18nService, authService };
}
jest.mock('@edx/frontend-platform/react/hooks', () => ({
...jest.requireActual('@edx/frontend-platform/react/hooks'),
useTrackColorSchemeChoice: jest.fn(),
}));
function render(ui, options) {
// eslint-disable-next-line react/prop-types
function Wrapper({ children }) {

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@edx/frontend-build');
const { createConfig } = require('@openedx/frontend-build');
const config = createConfig('webpack-dev');

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@edx/frontend-build');
const { createConfig } = require('@openedx/frontend-build');
const config = createConfig('webpack-prod');