Compare commits

...

100 Commits

Author SHA1 Message Date
Ihor Romaniuk
d9cce51d86 fix: date format depends on locale date format (#173) 2023-11-16 07:23:14 -03:00
vladislavkeblysh
925a7392cb feat: fixed lint 2023-11-14 12:37:33 -03:00
vladislavkeblysh
42bea23bd1 feat: fixed layout 2023-11-14 12:37:33 -03:00
Stanislav Lunyachek
833de88e1c fix: Missed favicon in Safari 2023-11-14 12:36:40 -03:00
Omar Al-Ithawi
bd85312ab3 feat: use atlas in make pull_translations on palm (#156)
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
 - Remove all broken and deprecated Tranisfex use
 - Install openedx-atlas

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-11-03 16:57:13 -04:00
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
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
Justin Hynes
fd3a49d7c6 Merge pull request #50 from openedx/renovate/npm-ejs-vulnerability
chore(deps): update dependency ejs to 3.1.7 [security]
2023-03-09 13:39:54 -05:00
renovate[bot]
a2b2d55db0 chore(deps): update dependency ejs to 3.1.7 [security]
* ignore lint errors (for now) to prioritize getting an updated version of the Comms MFE out with a compromised dependency
2023-03-09 13:27:42 -05:00
Sarina Canelake
fca2cce77c Merge pull request #118 from openedx/repo_checks/ensure_workflows
Update standard workflow files.
2023-02-28 09:35:03 -05:00
Jason Wesson
0dc2e65f60 Merge pull request #90 from openedx/renovate/edx-frontend-component-footer-11.x
fix(deps): update dependency @edx/frontend-component-footer to v11.6.0
2023-02-24 14:14:00 -08:00
Jason Wesson
cddc28c34f Merge pull request #108 from openedx/renovate/actions-setup-node-3.x
chore(deps): update actions/setup-node action to v3
2023-02-24 14:06:20 -08:00
Jason Wesson
56ca914fb4 Merge pull request #111 from openedx/renovate/codecov-codecov-action-3.x
chore(deps): update codecov/codecov-action action to v3
2023-02-24 13:45:32 -08:00
Jason Wesson
1ea43e0ad4 Merge pull request #106 from openedx/renovate/actions-checkout-3.x
chore(deps): update actions/checkout action to v3
2023-02-24 13:40:32 -08:00
Feanil Patel
9d25d6e4d0 build: Creating a missing workflow file add-remove-label-on-comment.yml.
The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 11:07:08 -05:00
Feanil Patel
2888cb6662 build: Creating a missing workflow file add-depr-ticket-to-depr-board.yml.
The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 11:07:08 -05:00
Feanil Patel
418c78d1f3 build: Creating a missing workflow file self-assign-issue.yml.
The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 11:07:07 -05:00
Mashal Malik
4231093347 Merge pull request #117 from Mashal-m/mashal-m/major-paragon-version-upgrade
build: major version upgrade of paragon
2022-12-28 11:43:57 +05:00
Muhammad Abdullah Waheed
b5b90272f8 refactor: updated renovate config to auto update minor and patch versions of edx dependencies (#74) 2022-12-20 13:19:32 +05:00
mashal-m
a08d30fbbb build: major version upgrade of paragon 2022-12-20 12:37:21 +05:00
mashal-m
ece65c83ad build: use shared browserslist configuration 2022-12-16 12:38:58 -05:00
renovate[bot]
fd98b4468e chore(deps): update codecov/codecov-action action to v3 2022-12-12 12:27:36 +00:00
renovate[bot]
4a8df3b50e chore(deps): update actions/setup-node action to v3 2022-12-12 12:27:28 +00:00
renovate[bot]
35f755ccf1 chore(deps): update actions/checkout action to v3 2022-12-12 12:27:22 +00:00
renovate[bot]
6b4bd3b534 fix(deps): update dependency @edx/frontend-component-footer to v11.6.0 2022-12-12 12:27:14 +00:00
renovate[bot]
2d9d195936 fix(deps): update dependency tinymce to v5.10.7 2022-12-12 12:22:24 +00:00
renovate[bot]
1082b27647 chore(deps): update dependency prettier to v2.8.1 2022-12-12 09:54:24 +00:00
Tim McCormack
9782cf108f build: Remove community-engineering CODEOWNERS (#112)
Team no longer exists. See <https://github.com/edx/edx-arch-experiments/issues/132>.
2022-12-09 19:41:48 +00:00
renovate[bot]
466fac7e9e fix(deps): update dependency @edx/frontend-component-header to v3.5.0 2022-12-05 11:02:31 +00:00
renovate[bot]
422632c582 chore(deps): update dependency prettier to v2.8.0 2022-11-28 09:19:27 +00:00
renovate[bot]
67b6512288 fix(deps): update dependency regenerator-runtime to v0.13.11 2022-11-21 10:26:48 +00:00
renovate[bot]
13ba06fd2a fix(deps): update dependency @edx/frontend-component-header to v3.4.1 2022-11-15 00:07:05 +00:00
renovate[bot]
61a2a4e8c9 fix(deps): update dependency core-js to v3.26.1 2022-11-14 08:47:19 +00:00
renovate[bot]
e112c3a6d1 fix(deps): update react-router monorepo to v5.3.4 2022-11-07 11:41:03 +00:00
renovate[bot]
97a21b9574 fix(deps): update dependency core-js to v3.26.0 2022-11-07 08:15:48 +00:00
Zubair Shakoor
22675fd17a fix: -t flag added in pull translation command (#100) 2022-10-28 14:54:27 +05:00
renovate[bot]
32327cde93 fix(deps): update dependency @edx/frontend-component-header to v3.3.0 2022-10-24 11:00:17 +00:00
renovate[bot]
dadbfed8e1 fix(deps): update dependency tinymce to v5.10.6 2022-10-24 07:14:55 +00:00
renovate[bot]
262ea5be0d fix(deps): update dependency redux to v4.2.0 2022-10-17 10:57:56 +00:00
renovate[bot]
fcb393d9e7 fix(deps): update dependency regenerator-runtime to v0.13.10 2022-10-17 07:58:39 +00:00
renovate[bot]
a6e84bf56c fix(deps): update dependency prop-types to v15.8.1 2022-10-10 11:36:55 +00:00
renovate[bot]
9cf76fec74 fix(deps): update dependency core-js to v3.25.5 2022-10-10 08:29:40 +00:00
renovate[bot]
0e22884a34 fix(deps): update dependency core-js to v3.25.4 2022-10-03 12:18:07 +00:00
edX requirements bot
1c0f1d6db9 fix: update organization references (#89) 2022-10-03 12:17:00 +05:00
renovate[bot]
c5a2070b6e fix(deps): update dependency react-redux to v7.2.9 2022-09-26 15:46:17 +00:00
renovate[bot]
6ac242aa0a fix(deps): update dependency classnames to v2.3.2 2022-09-26 11:24:33 +00:00
renovate[bot]
0130b5279f fix(deps): update dependency @edx/frontend-component-header to v3.2.1 2022-09-19 13:41:55 +00:00
renovate[bot]
d40450dabf fix(deps): update dependency @edx/frontend-component-footer to v11.2.1 2022-09-19 10:58:40 +00:00
renovate[bot]
61245d4423 fix(deps): update dependency axios to v0.27.2 2022-09-12 15:25:18 +00:00
renovate[bot]
aec96cd652 fix(deps): update dependency @tinymce/tinymce-react to v3.14.0 2022-09-12 11:43:04 +00:00
renovate[bot]
2c146b516f fix(deps): update dependency @fortawesome/react-fontawesome to v0.2.0 2022-09-05 23:42:57 +00:00
renovate[bot]
70d5c0e112 fix(deps): update dependency @edx/frontend-platform to v2.6.2 2022-09-05 18:28:38 +00:00
renovate[bot]
15325f0eb1 fix(deps): update dependency @edx/paragon to v19.25.3 2022-08-29 17:15:16 +00:00
renovate[bot]
25bb72b53b fix(deps): update dependency jquery to v3.6.1 2022-08-29 13:18:58 +00:00
Justin Hynes
0ef0cee7dc Merge pull request #75 from edx/jhynes/fix-build
fix: fix build by updating frontend-platform to v2.6.1
2022-08-24 15:00:12 -04:00
Justin Hynes
8e2cdb1b25 fix: fix build by updating frontend-platform to v2.6.1 2022-08-24 18:48:10 +00:00
renovate[bot]
f8212c24c3 chore(deps): update dependency prettier to v2.7.1 2022-08-22 10:33:03 +00:00
renovate[bot]
fcad43c485 chore(deps): update dependency jest to v27.5.1 2022-08-22 10:27:10 +00:00
Thomas Tracy
bc64c9e278 fix: [APER-1936] Changes for a11y review (#71)
* fix: [APER-1936] Changes for a11y review

- Fixes skip nav link to work properly
- inline styles pending tasks link
- adds <main>
- adds sr-only span to "view message" links in task history table
- header/footer updated in seperate PR

* chore: add sr-only span to unit test

* fix: fix space before sr-only span"
2022-08-19 10:31:30 -04:00
renovate[bot]
64d532aad5 chore(deps): update dependency es-check to v6.2.1 2022-08-15 10:52:25 +00:00
renovate[bot]
d05ed5ba26 chore(deps): update dependency axios-mock-adapter to v1.21.2 2022-08-15 10:44:29 +00:00
Justin Hynes
44e122142c Merge pull request #67 from edx/renovate/testing-library-jest-dom-5.x
chore(deps): update dependency @testing-library/jest-dom to v5.16.5
2022-08-09 09:05:13 -04:00
Justin Hynes
937f7a17c6 Merge pull request #68 from edx/renovate/axios-mock-adapter-1.x
chore(deps): update dependency axios-mock-adapter to v1.21.1
2022-08-09 09:01:34 -04:00
renovate[bot]
905dd98d28 chore(deps): update dependency @testing-library/jest-dom to v5.16.5 2022-08-09 12:19:46 +00:00
renovate[bot]
2927f938eb chore(deps): update dependency axios-mock-adapter to v1.21.1 2022-08-08 05:11:26 +00:00
Justin Hynes
156dd0e47b Merge pull request #65 from edx/jawayria/drop-12
chore!: drop support for Node12
2022-08-05 08:11:54 -04:00
Jawayria
dcf69d693f chore!: drop support for Node12
BREAKING CHANGE: Node 12 support dropped
2022-08-05 14:04:41 +05:00
Maxwell Frank
c905ede6fe fix: cohorts disabled when 'all learners' is selected 2022-08-04 14:17:57 -04:00
Thomas Tracy
eea663675b fix: [MICROBA-1903] validate date time (#62)
* fix: [MICROBA-1903] validate date time

Currently, we do not validate date and time to be a date on the future on the front end. We do handle this on the backend. This updates form validation to force the user to enter a data in the future.
2022-08-03 09:57:25 -04:00
Maxwell Frank
9c6644c1b9 Mfrank/microba 1881 microba 1908 (#63)
* refactor: replaced Pending Tasks with alert and made general styling/accesibility updates

* addressed linter flags

* refactor: replaced Pending Tasks with alert and made general styling/accesibility updates

* fixed linting issues again

* refactor: replaced Pending Tasks with alert and made general styling/accesibility updates

* fixed checkbox responsiveness and darkened text under input

Co-authored-by: Maxwell Frank <mfrank@2u.com>
2022-08-02 11:41:27 -04:00
renovate[bot]
8205ff64f8 chore(deps): update dependency @edx/frontend-build to v9.2.2 2022-08-01 07:21:03 +00:00
renovate[bot]
c0f41874bb fix(deps): update dependency tinymce to v5.10.5 2022-08-01 07:13:30 +00:00
Thomas Tracy
339f9b303f fix: await post to properly update form (#59) 2022-07-29 09:24:42 -04:00
Thomas Tracy
aec97816fb fix: [MICROBA-1877] fix cancel button (#56)
The various form states were getting mixed up during error handling,
which lead to some strange situations where the cancel button was
appearing when it shouldnt have been. This PR centralizes all of the
form state changes into a useEffect hook to handle any and all state
changes throughout the form. This approach not only centralizes the
fragmented code, but prevents the state mixups that were happening
previously.

It also has the added benefit if down the line more state changes need
to be added, the changes are all happening in one place.
2022-07-25 15:02:02 -04:00
renovate[bot]
b62a92c4d2 fix(deps): update dependency react-redux to v7.2.8 2022-07-25 11:38:22 +00:00
renovate[bot]
c950864afa chore(deps): update node.js to v12.22.12 2022-07-25 11:30:46 +00:00
Thomas Tracy
31669aa1f2 feat: [MICROBA-1902] Confirm email deletion (#55)
This PR adds an alert style pop up to confirm with the user that they
are deleting an email from the scheduled email table intentionally.
2022-07-21 12:58:58 -04:00
renovate[bot]
deae6c9654 chore(deps): update dependency glob to v7.2.3 2022-07-18 10:29:10 +00:00
renovate[bot]
1ede234b31 chore(deps): update dependency @testing-library/react to v12.1.5 2022-07-18 10:21:55 +00:00
Thomas Tracy
3deaeece2c feat: Add cancel button for editing an email (#49)
Right now, canceling edit mode is clunky. This adds a button to
explicitly cancel the mode for the editor.
2022-07-13 11:51:10 -04:00
renovate[bot]
b7275c1491 chore(deps): update dependency @testing-library/jest-dom to v5.16.4 2022-07-11 10:40:58 +00:00
renovate[bot]
f6058c9bbe fix(deps): pin dependencies 2022-07-11 10:33:55 +00:00
Maxwell Frank
5a20359856 WIP - Removing Pending tasks and styling changes - Microba 1824 (#47)
* refactor: replaced Pending Tasks with alert and made general styling/accesibility updates

* addressed linter flags

* refactor: replaced Pending Tasks with alert and made general styling/accesibility updates

* fixed linting issues again

* refactor: replaced Pending Tasks with alert and made general styling/accesibility updates

Co-authored-by: Maxwell Frank <mfrank@2u.com>
2022-06-28 10:04:55 -04:00
Thomas Tracy
99b9a51598 feat: [MICROBA-1840] delete/edit scheduled emails (#44)
This PR adds delete and edit functionality to emails that are scheduled to be sent at a future date. The workflow for editing is as such:

    A user clicks edit on the table
    The contents of the email are copied to the editor, and the user makes edits
    The user is warned that this is editing an email, and that it will not create a new task, instead updating the selected one
    If they accept, the email is updated.

For delete, the user just hits the delete button, and its gone. There is now warning popup as of yet.

This PR also adds the toast component for success and errors when sending emails.
2022-06-23 14:20:48 -04:00
Thomas Tracy
5d88b19c07 feat: Add ScheduledBulkEmailTable (#42)
This PR refactors some of the code around the context store to be more in line with the project organization ADR in this repo. Essentially, it splits the reducers and actions into slices used by the components that need them to prevent pollution of data in the store.

This PR also handles most of the refactor around the BulkEmailTool making use of the BulkEmailToolProvider in order to share data between components. This allows for better copyToEditor functionality, amongst other changes, as the email form now handles its state within the context store.

The Provider and its store is purposefully tied to the BulkEmailTool as to prevent any bleeding of state information between tools that may be added to the comms MFE in the future.

This PR also adds the first iteration of the scheduled emails table. This table will allow for viewing, deleting, and editing emails in the future. For now, it only adds viewing. The viewing modal DOES support copy to editor functionality but it is NOT editing the original entry and WILL schedule a new email if submitted.
2022-06-06 10:27:31 -04:00
Justin Hynes
9e29c54ac6 Merge pull request #43 from edx/jhynes/microba-1822_fix-access
fix: use `originalUserIsStaff` to gate bulk email tool over `isStaff`
2022-06-01 14:30:10 -04:00
Justin Hynes
298f573113 fix: use originalUserIsStaff to gate bulk email tool over isStaff
[MICROBA-1822][CR-4822]

* use `originalUserIsStaff` to gate bulk email tool access over `isStaff`

Additional Context
Access to this tool was originally gated by looking at the `isStaff` value from the CourseMetadata data returned to us from the LMS. The user masquerading feature seems to have some interesting interactions with the Instructor Dashboard and we may be denying legitimate staff/admin/instructors access to the tool. Instead of using the value of `isStaff` we will now use `originalUserIsStaff` to determine if the user accessing the tool should be allowed access (and this follows how the Learning MFE gates content).
2022-06-01 14:24:04 -04:00
Thomas Tracy
6dd09451a3 refactor: [MICROBA-1506] refactor message modal (#41)
This pr extracts out the message modal so that it can be reused
elsewhere in the app. Specifically so we can reuse it for the "view"
button in the scheduled emails table.
2022-05-17 12:05:27 -04:00
Thomas Tracy
ffa0361c22 feat: [MICROBA-1506] bulkEmailTool data context (#39)
Currently, our bulk email tool in concept looks something like this:
- BulkEmailTool
	- BulkEmailForm
	- BulkEmailTaskManager

Right now, the two components under the parent BulkEmailTool dont really
need to communicate with each other. For scheduled email, these two
components are going to be relying on the same data, and there need to
be provided that data by the parent. In order to make things more
manageable, this PR sets up some boilerplate and patterning for this
data. What this PR will include:
- Documentation around the pattern
- Necessary boilerplate to leverage the context store for the
  BulkEmailTool
- Tests around said store

What this PR will not include:
- Changes to the UI or form functionality
2022-05-16 11:43:04 -04:00
Tim McCormack
e09bb55544 fix: Fix pull_translations by using correct CLI flag for languages (#40)
Docs: https://developers.transifex.com/docs/using-the-client

Apparently this CLI option changed from singular to plural at some point.
2022-05-13 16:19:12 +00:00
edX requirements bot
5ce9ab00c8 feat: Add package-lock file version check (#38)
* feat: Add package-lock file version check

* fix: package-lock

Co-authored-by: Jawayria <jawayriahashmi@gmail.com>
2022-05-09 18:03:42 +05:00
Thomas Tracy
200f19dd9a feat: [MICROBA-1528] add schedule email UI (#36)
This handles a few things around the scheduled email UI. This includes:
1. Adding the schedule email UI date/time picker.
2. Adds states to the submit button for scheduling emails.
3. Drys out intl code and some submitting states.
4. Matches the email form UI to mocks.

This however does not include:
1. A table to show scheduled emails. Scheduled emails at the moment are
   displayed in the "pending tasks" section.
2. Matching the tasks section to the mocks.
2022-05-04 12:49:02 -04:00
Thomas Tracy
a37d63a4a3 chore: update edx frontend packages (#35) 2022-04-25 09:22:31 -04:00
Thomas Tracy
d67348929d fix: [MICROBA-1799] fix bulk email tool on safari (#34)
Course teams were having issues sending bulk emails to themselves and
students. This was caused by two problems.

1. The language selector tool was failing because of no default language
   settings in safari is possible
2. The translated string for the "continue" button on the submit modal
   was messing with the markup and causing the event to POST the email
to not properly fire.

To fix the language issue for now, we are disabling the language
selector plugin. To fix the markup issue, we are forcing the string to
render in a fragment to remove the additional span, allowing the event
to fire no matter where the user clicks the button.
2022-04-15 13:47:24 -04:00
82 changed files with 13123 additions and 42095 deletions

3
.env
View File

@@ -18,3 +18,6 @@ REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
USER_INFO_COOKIE_NAME=''
SCHEDULE_EMAIL_SECTION=''
APP_ID=''
MFE_CONFIG_API_URL=''

View File

@@ -19,3 +19,6 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
SCHEDULE_EMAIL_SECTION='true'
APP_ID=''
MFE_CONFIG_API_URL=''

View File

@@ -17,3 +17,6 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
SCHEDULE_EMAIL_SECTION='true'
APP_ID=''
MFE_CONFIG_API_URL=''

View File

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

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @edx/community-engineering

View File

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

View File

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

View File

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

View File

@@ -7,4 +7,4 @@ on:
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master
uses: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -0,0 +1,13 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

12
.github/workflows/self-assign-issue.yml vendored Normal file
View File

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

2
.nvmrc
View File

@@ -1,2 +1,2 @@
12.22.7
18

View File

@@ -1,11 +1,7 @@
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
@@ -33,20 +29,17 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-app-communications/src/i18n/messages:frontend-app-communications
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-app-communications
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

52023
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,18 +7,16 @@
"url": "git+https://github.com/edx/frontend-app-communications.git"
},
"browserslist": [
"last 2 versions",
"ie 11"
"extends @edx/browserslist-config"
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
@@ -36,41 +34,45 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-component-header": "^2.4.2",
"@edx/frontend-platform": "1.12.7",
"@edx/paragon": "16.3.2",
"@edx/tinymce-language-selector": "^1.1.0",
"@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-component-header": "^4.0.0",
"@edx/frontend-platform": "^4.2.0",
"@edx/openedx-atlas": "^0.5.0",
"@edx/paragon": "^20.20.0",
"@edx/tinymce-language-selector": "1.1.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.16",
"@tinymce/tinymce-react": "^3.13.0",
"classnames": "^2.3.1",
"core-js": "3.15.2",
"prop-types": "15.7.2",
"@fortawesome/react-fontawesome": "0.2.0",
"@tinymce/tinymce-react": "3.14.0",
"axios": "0.27.2",
"classnames": "2.3.2",
"core-js": "3.26.1",
"jquery": "3.6.1",
"popper.js": "1.16.1",
"prop-types": "15.8.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "7.2.6",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"redux": "4.1.2",
"regenerator-runtime": "0.13.9",
"tinymce": "^5.10.2"
"react-helmet": "^6.1.0",
"react-redux": "7.2.9",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"redux": "4.2.0",
"regenerator-runtime": "0.13.11",
"tinymce": "5.10.7"
},
"devDependencies": {
"@edx/frontend-build": "8.1.6",
"axios-mock-adapter": "^1.20.0",
"codecov": "3.8.3",
"es-check": "6.1.1",
"glob": "7.2.0",
"@edx/browserslist-config": "^1.2.0",
"@edx/frontend-build": "^12.7.0",
"@edx/reactifex": "^2.1.1",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"axios-mock-adapter": "1.21.2",
"glob": "7.2.3",
"husky": "7.0.4",
"jest": "27.3.1",
"prettier": "^2.5.1",
"reactifex": "1.1.1",
"rosie": "^2.1.0",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2"
"jest": "27.5.1",
"prettier": "2.8.1",
"rosie": "2.1.0"
}
}

View File

@@ -22,6 +22,11 @@
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"timezone": "America/New_York"

View File

@@ -1,41 +1,46 @@
import React, { useRef } from 'react';
import classnames from 'classnames';
import React from 'react';
import { useParams } from 'react-router-dom';
import { ErrorPage } from '@edx/frontend-platform/react';
import { Container } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import BulkEmailTaskManager from './bulk-email-task-manager/BulkEmailTaskManager';
import NavigationTabs from '../navigation-tabs/NavigationTabs';
import useMobileResponsive from '../../utils/useMobileResponsive';
import BulkEmailForm from './bulk-email-form';
import { CourseMetadataContext } from '../page-container/PageContainer';
import { BulkEmailProvider } from './bulk-email-context';
import BackToInstructor from '../navigation-tabs/BackToInstructor';
export default function BulkEmailTool() {
const { courseId } = useParams();
const isMobile = useMobileResponsive();
const textEditorRef = useRef();
const copyTextToEditor = (body) => {
if (textEditorRef?.current) {
textEditorRef.current.setContent(body);
}
};
return (
<CourseMetadataContext.Consumer>
{(courseMetadata) => (courseMetadata.isStaff ? (
<div>
{(courseMetadata) => (courseMetadata.originalUserIsStaff ? (
<>
<NavigationTabs courseId={courseId} tabData={courseMetadata.tabs} />
<div className={classnames({ 'border border-primary-200': !isMobile })}>
<div className="row">
<BulkEmailForm courseId={courseId} cohorts={courseMetadata.cohorts} editorRef={textEditorRef} />
</div>
<div className="row">
<BulkEmailTaskManager courseId={courseId} copyTextToEditor={copyTextToEditor} />
</div>
</div>
</div>
<BulkEmailProvider>
<Container size="md">
<BackToInstructor courseId={courseId} />
<div className="row pb-4.5">
<h1 className="text-primary-500" id="main-content">
<FormattedMessage
id="bulk.email.send.email.header"
defaultMessage="Send an email"
description="A label for email form"
/>
</h1>
</div>
<div className="row">
<BulkEmailForm courseId={courseId} cohorts={courseMetadata.cohorts} />
</div>
<div className="row py-5">
<BulkEmailTaskManager courseId={courseId} />
</div>
</Container>
</BulkEmailProvider>
</>
) : (
<ErrorPage />
))}

View File

@@ -0,0 +1,27 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React from 'react';
import PropTypes from 'prop-types';
import useAsyncReducer, { combineReducers } from '../../../utils/useAsyncReducer';
import editor, { editorInitialState } from '../bulk-email-form/data/reducer';
import scheduledEmailsTable, {
scheduledEmailsTableInitialState,
} from '../bulk-email-task-manager/bulk-email-scheduled-emails-table/data/reducer';
export const BulkEmailContext = React.createContext();
export default function BulkEmailProvider({ children }) {
const initialState = {
editor: editorInitialState,
scheduledEmailsTable: scheduledEmailsTableInitialState,
};
const [state, dispatch] = useAsyncReducer(
combineReducers({ editor, scheduledEmailsTable }),
initialState,
);
return <BulkEmailContext.Provider value={[state, dispatch]}>{children}</BulkEmailContext.Provider>;
}
BulkEmailProvider.propTypes = {
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};

View File

@@ -0,0 +1 @@
export { default as BulkEmailProvider, BulkEmailContext } from './BulkEmailProvider';

View File

@@ -1,112 +1,264 @@
import React, { useState } from 'react';
/* eslint-disable react/no-unstable-nested-components */
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Form, Icon, StatefulButton, useCheckboxSetValues, useToggle,
Button,
Form, Icon, StatefulButton, Toast, useToggle,
} from '@edx/paragon';
import { SpinnerSimple, CheckCircle, Cancel } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
SpinnerSimple, Cancel, Send, Event, Check,
} from '@edx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import { getConfig } from '@edx/frontend-platform';
import TextEditor from '../text-editor/TextEditor';
import { postBulkEmail } from './api';
import BulkEmailRecipient from './bulk-email-recipient';
import TaskAlertModal from './TaskAlertModal';
import TaskAlertModal from '../task-alert-modal';
import useTimeout from '../../../utils/useTimeout';
import useMobileResponsive from '../../../utils/useMobileResponsive';
import ScheduleEmailForm from './ScheduleEmailForm';
import messages from './messages';
import { BulkEmailContext } from '../bulk-email-context';
import {
addRecipient,
clearEditor,
clearErrorState,
handleEditorChange,
removeRecipient,
} from './data/actions';
import { editScheduledEmailThunk, postBulkEmailThunk } from './data/thunks';
import { getScheduledBulkEmailThunk } from '../bulk-email-task-manager/bulk-email-scheduled-emails-table/data/thunks';
import './bulkEmailForm.scss';
export const FORM_SUBMIT_STATES = {
DEFAULT: 'default',
PENDING: 'pending',
COMPLETE: 'complete',
COMPLETED_DEFAULT: 'completed_default',
COMPLETE_SCHEDULE: 'completed_schedule',
SCHEDULE: 'schedule',
RESCHEDULE: 'reschedule',
ERROR: 'error',
};
export default function BulkEmailForm(props) {
const { courseId, cohorts, editorRef } = props;
const [subject, setSubject] = useState('');
const FORM_ACTIONS = {
POST: 'POST',
PATCH: 'PATCH',
};
function BulkEmailForm(props) {
const { courseId, cohorts, intl } = props;
const [{ editor }, dispatch] = useContext(BulkEmailContext);
const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT);
const [emailFormValidation, setEmailFormValidation] = useState({
// set these as true on initialization, to prevent invalid messages from prematurely showing
subject: true,
body: true,
recipients: true,
schedule: true,
});
const [selectedRecipients, { add, remove }] = useCheckboxSetValues([]);
const [isTaskAlertOpen, openTaskAlert, closeTaskAlert] = useToggle(false);
const resetEmailForm = useTimeout(() => {
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETED_DEFAULT);
}, 3000);
const [isScheduled, toggleScheduled] = useState(false);
const isMobile = useMobileResponsive();
/**
* Since we are working with both an old and new API endpoint, the body for the POST
* and the PATCH have different signatures. Therefore, based on the action required, we need to
* format the data properly to be accepted on the back end.
* @param {*} action "POST" or "PATCH" of the FORM_ACTIONS constant
* @returns formatted Data
*/
const formatDataForFormAction = (action) => {
if (action === FORM_ACTIONS.POST) {
const emailData = new FormData();
emailData.append('action', 'send');
emailData.append('send_to', JSON.stringify(editor.emailRecipients));
emailData.append('subject', editor.emailSubject);
emailData.append('message', editor.emailBody);
if (isScheduled) {
emailData.append('schedule', new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toISOString());
}
return emailData;
}
if (action === FORM_ACTIONS.PATCH) {
return {
email: {
targets: editor.emailRecipients,
subject: editor.emailSubject,
message: editor.emailBody,
id: editor.emailId,
},
schedule: isScheduled ? new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toISOString() : null,
};
}
return {};
};
/**
* This function resets the form based on what state the form is currently in. Used after
* successfully sending or scheduling and email, or on error.
*
* @param {Boolean} error If true, resets just the state of the form, and not the editor.
* if false, reset the form completely, and wipe all email data form the form.
*/
const resetEmailForm = (error) => {
if (error) {
dispatch(clearErrorState());
} else {
dispatch(clearEditor());
}
};
/**
* Allows for a delayed form reset, to give the user time to process completion and error
* states before reseting the form.
*/
const delayedEmailFormReset = useTimeout(
() => resetEmailForm(editor.errorRetrievingData),
3000,
);
const onFormChange = (event) => dispatch(handleEditorChange(event.target.name, event.target.value));
const onRecipientChange = (event) => {
if (event.target.checked) {
add(event.target.value);
dispatch(addRecipient(event.target.value));
// if "All Learners" is checked then we want to remove any cohorts, verified learners, and audit learners
if (event.target.value === 'learners') {
editor.emailRecipients.forEach(recipient => {
if (/^cohort/.test(recipient) || /^track/.test(recipient)) {
dispatch(removeRecipient(recipient));
}
});
}
} else {
remove(event.target.value);
dispatch(removeRecipient(event.target.value));
}
};
const onInit = (event, editor) => {
editorRef.current = editor;
const validateDateTime = (date, time) => {
if (isScheduled) {
const now = new Date();
const newSchedule = new Date(`${editor.scheduleDate} ${editor.scheduleTime}`);
return !!date && !!time && newSchedule > now;
}
return true;
};
const onSubjectChange = (event) => setSubject(event.target.value);
const validateEmailForm = () => {
const subjectValid = subject.length !== 0;
const bodyValid = editorRef.current.getContent().length !== 0;
const recipientsValid = selectedRecipients.length !== 0;
const subjectValid = editor.emailSubject.length !== 0;
const bodyValid = editor.emailBody.length !== 0;
const recipientsValid = editor.emailRecipients.length !== 0;
const scheduleValid = validateDateTime(editor.scheduleDate, editor.scheduleTime);
setEmailFormValidation({
subject: subjectValid,
recipients: recipientsValid,
body: bodyValid,
schedule: scheduleValid,
});
return subjectValid && bodyValid && recipientsValid;
return subjectValid && bodyValid && recipientsValid && scheduleValid;
};
const createEmailTask = async () => {
const emailData = new FormData();
if (validateEmailForm()) {
setEmailFormStatus(() => FORM_SUBMIT_STATES.PENDING);
emailData.append('action', 'send');
emailData.append('send_to', JSON.stringify(selectedRecipients));
emailData.append('subject', subject);
emailData.append('message', editorRef.current.getContent());
let data;
try {
data = await postBulkEmail(emailData, courseId);
} catch (e) {
setEmailFormStatus(FORM_SUBMIT_STATES.ERROR);
return;
}
if (data.status === 200) {
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE);
resetEmailForm();
if (editor.editMode) {
const editedEmail = formatDataForFormAction(FORM_ACTIONS.PATCH);
await dispatch(editScheduledEmailThunk(editedEmail, courseId, editor.schedulingId));
} else {
const emailData = formatDataForFormAction(FORM_ACTIONS.POST);
await dispatch(postBulkEmailThunk(emailData, courseId));
}
dispatch(getScheduledBulkEmailThunk(courseId, 1));
}
};
/**
* State manager for the various states the form can be in at any given time.
* The states of the form are based off various pieces of the editor store, and
* calculates what state and whether to reset the form based on these booleans.
* Any time the form needs to change state, the conditions for that state change should
* placed here to prevent unecessary rerenders and implicit/flakey state update batching.
*/
useEffect(() => {
if (editor.isLoading) {
setEmailFormStatus(FORM_SUBMIT_STATES.PENDING);
return;
}
if (editor.errorRetrievingData) {
setEmailFormStatus(FORM_SUBMIT_STATES.ERROR);
delayedEmailFormReset();
return;
}
if (editor.formComplete) {
if (isScheduled) {
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE_SCHEDULE);
} else {
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE);
}
delayedEmailFormReset();
return;
}
if (editor.editMode === true) {
toggleScheduled(true);
setEmailFormStatus(FORM_SUBMIT_STATES.RESCHEDULE);
} else if (isScheduled) {
setEmailFormStatus(FORM_SUBMIT_STATES.SCHEDULE);
} else {
setEmailFormStatus(FORM_SUBMIT_STATES.DEFAULT);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScheduled, editor.editMode, editor.isLoading, editor.errorRetrievingData, editor.formComplete]);
const AlertMessage = () => (
<>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertRecipients, { subject: editor.emailSubject })}</p>
<ul className="list-unstyled">
{editor.emailRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
{!isScheduled && (
<p>
<strong>{intl.formatMessage(messages.bulkEmailInstructionsCaution)}</strong>
{intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)}
</p>
)}
</>
);
const EditMessage = () => (
<>
<p>
{intl.formatMessage(messages.bulkEmailTaskAlertEditingDate, {
dateTime: new Date(`${editor.scheduleDate} ${editor.scheduleTime}`).toLocaleString(),
})}
</p>
<p>
{intl.formatMessage(messages.bulkEmailTaskAlertEditingSubject, {
subject: editor.emailSubject,
})}
</p>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertEditingTo)}</p>
<ul className="list-unstyled">
{editor.emailRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
<p>{intl.formatMessage(messages.bulkEmailTaskAlertEditingWarning)}</p>
{!isScheduled && (
<p>
<strong>{intl.formatMessage(messages.bulkEmailInstructionsCaution)}</strong>
{intl.formatMessage(messages.bulkEmailInstructionsCautionMessage)}
</p>
)}
</>
);
return (
<div className="w-100 m-auto p-lg-4 py-2.5 px-5">
<div className={classNames('w-100 m-auto', !isMobile && 'p-4 border border-primary-200')}>
<TaskAlertModal
isOpen={isTaskAlertOpen}
alertMessage={(
<>
<p>
<FormattedMessage
id="bulk.email.task.alert.recipients"
defaultMessage="You are sending an email message with the subject {subject} to the following recipients:"
description="A warning shown to the user after submitting the email, to confirm the email recipients."
values={{
subject,
}}
/>
</p>
<ul className="list-unstyled">
{selectedRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
</>
)}
alertMessage={editor.editMode ? EditMessage() : AlertMessage()}
close={(event) => {
closeTaskAlert();
if (event.target.name === 'continue') {
@@ -115,127 +267,109 @@ export default function BulkEmailForm(props) {
}}
/>
<Form>
<p className="h2">
<FormattedMessage
id="bulk.email.tool.label"
defaultMessage="Email"
description="Tool label. Describes the function of the tool (to send email)."
/>
</p>
<BulkEmailRecipient
selectedGroups={selectedRecipients}
selectedGroups={editor.emailRecipients}
handleCheckboxes={onRecipientChange}
additionalCohorts={cohorts}
isValid={emailFormValidation.recipients}
/>
<Form.Group controlId="emailSubject">
<Form.Label>
<FormattedMessage
id="bulk.email.subject.label"
defaultMessage="Subject:"
description="Email subject line input label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<Form.Control name="subject" className="w-lg-50" onChange={onSubjectChange} />
<Form.Label className="h3 text-primary-500">{intl.formatMessage(messages.bulkEmailSubjectLabel)}</Form.Label>
<Form.Control name="emailSubject" className="w-lg-50" onChange={onFormChange} value={editor.emailSubject} />
{!emailFormValidation.subject && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.subject.error"
defaultMessage="A subject is required"
description="An Error message located under the subject line. Visible only on failure."
/>
{intl.formatMessage(messages.bulkEmailFormSubjectError)}
</Form.Control.Feedback>
)}
</Form.Group>
<Form.Group controlId="emailBody">
<Form.Label>
<FormattedMessage
id="bulk.email.body.label"
defaultMessage="Body:"
description="Email Body label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<TextEditor onInit={onInit} />
<Form.Label className="h3 text-primary-500">{intl.formatMessage(messages.bulkEmailBodyLabel)}</Form.Label>
<TextEditor onChange={(value) => dispatch(handleEditorChange('emailBody', value))} value={editor.emailBody} />
{!emailFormValidation.body && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.body.error"
defaultMessage="The message cannot be blank"
description="An error message located under the body editor. Visible only on failure."
/>
{intl.formatMessage(messages.bulkEmailFormBodyError)}
</Form.Control.Feedback>
)}
</Form.Group>
<div>
<p>
<FormattedMessage
id="bulk.email.instructions.proofreading"
defaultMessage="We recommend sending learners no more than one email message per week. Before you send your email, review
the text carefully and send it to yourself first, so that you can preview the formatting and make sure
embedded images and links work correctly."
description="A set of instructions to give users a heads up about the formatting of the email they are about to send"
/>
</p>
<p>
<strong>
<FormattedMessage id="bulk.email.instructions.caution" defaultMessage="Caution!" />
</strong>
<FormattedMessage
id="bulk.email.instructions.caution.message"
defaultMessage=" When you select Send Email, your email message is added to the queue for sending,
and cannot be cancelled."
description="A warning about how emails are sent out to users"
/>
</p>
<p>{intl.formatMessage(messages.bulkEmailInstructionsProofreading)}</p>
</div>
<Form.Group className="d-flex flex-row">
<StatefulButton
variant="primary"
type="submit"
onClick={(event) => {
event.preventDefault();
openTaskAlert();
}}
state={emailFormStatus}
icons={{
default: <Icon className="icon-download" />,
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
complete: <Icon src={CheckCircle} />,
error: <Icon src={Cancel} />,
}}
labels={{
default: 'Submit',
pending: 'Submitting',
complete: 'Task Created',
error: 'Error',
}}
disabledStates={['pending', 'complete']}
>
<FormattedMessage
id="bulk.email.submit.button"
defaultMessage="Submit"
description="Submit/Send email button"
<Form.Group>
{getConfig().SCHEDULE_EMAIL_SECTION && (
<div className="mb-3">
<Form.Checkbox
name="scheduleEmailBox"
checked={isScheduled}
onChange={() => toggleScheduled((prev) => !prev)}
disabled={emailFormStatus === FORM_SUBMIT_STATES.PENDING}
>
{intl.formatMessage(messages.bulkEmailFormScheduleBox)}
</Form.Checkbox>
</div>
)}
{isScheduled && (
<ScheduleEmailForm
isValid={emailFormValidation.schedule}
onDateTimeChange={onFormChange}
dateTime={{ date: editor.scheduleDate, time: editor.scheduleTime }}
/>
</StatefulButton>
{emailFormStatus === FORM_SUBMIT_STATES.ERROR && (
<Form.Control.Feedback className="px-3" hasIcon={false} type="invalid">
<FormattedMessage
id="bulk.email.form.error"
defaultMessage="An error occured while attempting to send the email."
description="An Error message located under the submit button for the email form. Visible only on a failure."
/>
</Form.Control.Feedback>
)}
{(emailFormStatus === FORM_SUBMIT_STATES.COMPLETED_DEFAULT
|| emailFormStatus === FORM_SUBMIT_STATES.COMPLETE) && (
<Form.Control.Feedback className="px-3" hasIcon={false} type="valid">
<FormattedMessage
id="bulk.email.form.complete"
defaultMessage="A task to send the emails has been successfully created!"
description="A success message displays under the submit button when successfully completing the form."
/>
</Form.Control.Feedback>
)}
<div
className={classNames('d-flex', {
'mt-n4.5': !isScheduled && !isMobile,
'flex-row-reverse align-items-end': !isMobile,
'border-top pt-2': isScheduled,
})}
>
{editor.editMode && <Button className="ml-2" variant="outline-brand" onClick={() => dispatch(clearEditor())}>Cancel</Button>}
<StatefulButton
className="send-email-btn"
variant="primary"
onClick={(event) => {
event.preventDefault();
openTaskAlert();
}}
state={emailFormStatus}
icons={{
[FORM_SUBMIT_STATES.DEFAULT]: <Icon src={Send} />,
[FORM_SUBMIT_STATES.SCHEDULE]: <Icon src={Event} />,
[FORM_SUBMIT_STATES.RESCHEDULE]: <Icon src={Event} />,
[FORM_SUBMIT_STATES.PENDING]: <Icon src={SpinnerSimple} className="icon-spin" />,
[FORM_SUBMIT_STATES.COMPLETE]: <Icon src={Check} />,
[FORM_SUBMIT_STATES.COMPLETE_SCHEDULE]: <Icon src={Check} />,
[FORM_SUBMIT_STATES.ERROR]: <Icon src={Cancel} />,
}}
labels={{
[FORM_SUBMIT_STATES.DEFAULT]: intl.formatMessage(messages.bulkEmailSubmitButtonDefault),
[FORM_SUBMIT_STATES.SCHEDULE]: intl.formatMessage(messages.bulkEmailSubmitButtonSchedule),
[FORM_SUBMIT_STATES.RESCHEDULE]: intl.formatMessage(messages.bulkEmailSubmitButtonReschedule),
[FORM_SUBMIT_STATES.PENDING]: intl.formatMessage(messages.bulkEmailSubmitButtonPending),
[FORM_SUBMIT_STATES.COMPLETE]: intl.formatMessage(messages.bulkEmailSubmitButtonComplete),
[FORM_SUBMIT_STATES.COMPLETE_SCHEDULE]: intl.formatMessage(
messages.bulkEmailSubmitButtonCompleteSchedule,
),
[FORM_SUBMIT_STATES.ERROR]: intl.formatMessage(messages.bulkEmailSubmitButtonError),
}}
disabledStates={[
FORM_SUBMIT_STATES.PENDING,
FORM_SUBMIT_STATES.COMPLETE,
FORM_SUBMIT_STATES.COMPLETE_SCHEDULE,
]}
/>
<Toast
show={
emailFormStatus === FORM_SUBMIT_STATES.ERROR
|| emailFormStatus === FORM_SUBMIT_STATES.COMPLETE
|| emailFormStatus === FORM_SUBMIT_STATES.COMPLETE_SCHEDULE
}
onClose={() => resetEmailForm(emailFormStatus === FORM_SUBMIT_STATES.ERROR)}
>
{emailFormStatus === FORM_SUBMIT_STATES.ERROR && intl.formatMessage(messages.bulkEmailFormError)}
{emailFormStatus === FORM_SUBMIT_STATES.COMPLETE && intl.formatMessage(messages.bulkEmailFormSuccess)}
{emailFormStatus === FORM_SUBMIT_STATES.COMPLETE_SCHEDULE
&& intl.formatMessage(messages.bulkEmailFormScheduledSuccess)}
</Toast>
</div>
</Form.Group>
</Form>
</div>
@@ -249,6 +383,7 @@ BulkEmailForm.defaultProps = {
BulkEmailForm.propTypes = {
courseId: PropTypes.string.isRequired,
cohorts: PropTypes.arrayOf(PropTypes.string),
editorRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(Element) })])
.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(BulkEmailForm);

View File

@@ -0,0 +1,97 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import useMobileResponsive from '../../../utils/useMobileResponsive';
function ScheduleEmailForm(props) {
const isMobile = useMobileResponsive();
const { isValid, onDateTimeChange, dateTime } = props;
const { date, time } = dateTime;
const descriptionDate = new Date();
descriptionDate.setDate(new Date().getDate() + 1);
return (
<Form.Group>
<div className={classNames('d-flex', isMobile ? 'flex-column' : 'flex-row', 'my-3')}>
<div className="w-md-50 mx-2">
<Form.Control
type="date"
name="scheduleDate"
data-testid="scheduleDate"
onChange={onDateTimeChange}
value={date}
floatingLabel={(
<FormattedMessage
id="bulk.email.form.schedule.date"
defaultMessage="Send date"
description="Label for the date portion of the email schedule form"
/>
)}
/>
<small className="text-gray-500 x-small">
<FormattedMessage
id="bulk.email.form.schedule.date.description"
defaultMessage="Enter a start date, e.g. {date}"
values={{
date: descriptionDate.toLocaleDateString(),
}}
/>
</small>
</div>
<div className="w-md-50 mx-2">
<Form.Control
type="time"
name="scheduleTime"
data-testid="scheduleTime"
onChange={onDateTimeChange}
value={time}
floatingLabel={(
<FormattedMessage
id="bulk.email.form.schedule.time"
defaultMessage="Send time"
description="Label for the time portion of the email schedule form"
/>
)}
/>
<small className="text-gray-500 x-small">
<FormattedMessage
id="bulk.email.form.schedule.time.description"
defaultMessage="Enter a start time, e.g. {time}"
values={{
time: descriptionDate.toLocaleTimeString([], { timeStyle: 'short' }),
}}
/>
</small>
</div>
</div>
{!isValid && (
<Form.Control.Feedback className="pb-2" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.dateTime.error"
defaultMessage="Date and time cannot be blank, and must be a date in the future"
description="An error message located under the date-time selector. Visible only on failure."
/>
</Form.Control.Feedback>
)}
</Form.Group>
);
}
ScheduleEmailForm.defaultProps = {
dateTime: {
date: '',
time: '',
},
};
ScheduleEmailForm.propTypes = {
isValid: PropTypes.bool.isRequired,
onDateTimeChange: PropTypes.func.isRequired,
dateTime: PropTypes.shape({
date: PropTypes.string,
time: PropTypes.string,
}),
};
export default ScheduleEmailForm;

View File

@@ -1,8 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export async function postBulkEmail(email, courseId) {
const url = `${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor/api/send_email`;
return getAuthenticatedHttpClient().post(url, email);
}

View File

@@ -18,36 +18,73 @@ export default function BulkEmailRecipient(props) {
return (
<Form.Group>
<Form.Label>
<FormattedMessage
id="bulk.email.form.recipients.sendLabel"
defaultMessage="Send To:"
description="A label before the list of potential recipients"
/>
<span className="h3 text-primary-500">
<FormattedMessage
id="bulk.email.form.recipients.sendLabel"
defaultMessage="Send to"
description="A label before the list of potential recipients"
/>
</span>
</Form.Label>
<Form.CheckboxSet
name="recipientGroups"
className="flex-wrap flex-row recipient-groups w-75"
className="flex-wrap flex-row recipient-groups w-100"
onChange={handleCheckboxes}
value={selectedGroups}
>
<Form.Checkbox key="myself" value="myself" className="mt-2.5">
<Form.Checkbox key="myself" value="myself" className="mt-2.5 col col-lg-4 col-sm-6 col-12">
<FormattedMessage
id="bulk.email.form.recipients.myself"
defaultMessage="Myself"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox key="staff" value="staff">
<Form.Checkbox
key="staff"
value="staff"
className="col col-lg-4 col-sm-6 col-12"
>
<FormattedMessage
id="bulk.email.form.recipients.staff"
defaultMessage="Staff/Administrators"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox
key="track:verified"
value="track:verified"
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
className="col col-lg-4 col-sm-6 col-12"
>
<FormattedMessage
id="bulk.email.form.recipients.verified"
defaultMessage="Learners in the verified certificate track"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
{
// additional cohorts
additionalCohorts
&& additionalCohorts.map((cohort) => (
<Form.Checkbox
key={cohort}
value={`cohort:${cohort}`}
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
className="col col-lg-4 col-sm-6 col-12"
>
<FormattedMessage
id="bulk.email.form.cohort.label"
defaultMessage="Cohort: {cohort}"
values={{ cohort }}
/>
</Form.Checkbox>
))
}
<Form.Checkbox
key="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"
@@ -55,21 +92,10 @@ 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)}
>
<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>
<Form.Checkbox
key="learners"
value="learners"
disabled={selectedGroups.find((group) => group === (DEFAULT_GROUPS.AUDIT || DEFAULT_GROUPS.VERIFIED))}
className="col col-lg-4 col-sm-6 col-12"
>
<FormattedMessage
id="bulk.email.form.recipients.learners"
@@ -77,22 +103,6 @@ export default function BulkEmailRecipient(props) {
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
{
// additional cohorts
additionalCohorts
&& additionalCohorts.map((cohort) => (
<Form.Checkbox
key={cohort}
value={`cohort:${cohort}`}
>
<FormattedMessage
id="bulk.email.form.cohort.label"
defaultMessage="Cohort: {cohort}"
values={{ cohort }}
/>
</Form.Checkbox>
))
}
</Form.CheckboxSet>
{!props.isValid && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">

View File

@@ -1,6 +1,5 @@
.recipient-groups {
> div {
min-width: 50%;
padding-right: 0.5rem;
input {
padding: 0.5rem !important;

View File

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

View File

@@ -0,0 +1,10 @@
// Flip a leading icon to be a trailing icon
.send-email-btn {
> span {
flex-direction: row-reverse;
gap: 0.5rem;
> span {
margin: 0;
}
}
}

View File

@@ -0,0 +1,7 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
export default Factory.define('')
.attr('cohorts', [
'Gnarly',
'Righteous',
]);

View File

@@ -0,0 +1,74 @@
export const handleEditorChange = (fieldName, fieldValue) => ({
type: 'EDITOR_ON_CHANGE',
payload: {
[fieldName]: fieldValue,
},
});
export const clearEditor = () => ({
type: 'CLEAR_EDITOR',
});
export const clearErrorState = () => ({
type: 'CLEAR_ERROR',
});
export const copyToEditor = (payload) => ({
type: 'COPY_TO_EDITOR',
payload,
});
export const addRecipient = (payload) => ({
type: 'ADD_RECIPIENT',
payload,
});
export const removeRecipient = (payload) => ({
type: 'REMOVE_RECIPIENT',
payload,
});
export const clearRecipients = () => ({
type: 'CLEAR_RECIPIENTS',
});
export const clearDateTime = () => ({
type: 'CLEAR_DATE_TIME',
});
export const setEditMode = (editMode = false) => ({
type: 'SET_EDIT_MODE',
payload: editMode,
});
export const patchScheduledEmail = () => ({
type: 'PATCH_SCHEDULED_EMAIL',
});
export const patchScheduledEmailStart = () => ({
type: 'PATCH_START',
});
export const patchScheduledEmailComplete = () => ({
type: 'PATCH_COMPLETE',
});
export const patchScheduledEmailError = () => ({
type: 'PATCH_FAILURE',
});
export const postBulkEmail = () => ({
type: 'POST_BULK_EMAIL',
});
export const postBulkEmailStart = () => ({
type: 'POST_START',
});
export const postBulkEmailComplete = () => ({
type: 'POST_COMPLETE',
});
export const postBulkEmailError = () => ({
type: 'POST_FAILURE',
});

View File

@@ -0,0 +1,26 @@
/* eslint-disable import/prefer-default-export */
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
export async function postBulkEmailInstructorTask(email, courseId) {
try {
const url = `${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor/api/send_email`;
const response = await getAuthenticatedHttpClient().post(url, email);
return response;
} catch (error) {
logError(error);
throw new Error(error);
}
}
export async function patchScheduledBulkEmailInstructorTask(emailData, courseId, scheduleId) {
const endpointUrl = `${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/${courseId}/bulk_email/${scheduleId}`;
try {
const response = await getAuthenticatedHttpClient().patch(endpointUrl, emailData);
return response;
} catch (error) {
logError(error);
throw new Error(error);
}
}

View File

@@ -0,0 +1,130 @@
export function editorReducer(state, action) {
switch (action.type) {
case 'EDITOR_ON_CHANGE':
return {
...state,
...action.payload,
};
case 'COPY_TO_EDITOR':
return {
...state,
emailBody: action.payload.emailBody || '',
emailSubject: action.payload.emailSubject || '',
emailRecipients: action.payload.emailRecipients || [],
scheduleDate: action.payload.scheduleDate || '',
scheduleTime: action.payload.scheduleTime || '',
schedulingId: action.payload.schedulingId || '',
emailId: action.payload.emailId || null,
editMode: action.payload.editMode || false,
};
case 'ADD_RECIPIENT':
return {
...state,
emailRecipients: [...state.emailRecipients, action.payload],
};
case 'REMOVE_RECIPIENT':
return {
...state,
emailRecipients: state.emailRecipients.filter((value) => value !== action.payload),
};
case 'CLEAR_RECIPIENTS':
return {
...state,
emailRecipients: [],
};
case 'CLEAR_DATE_TIME':
return {
...state,
scheduleDate: '',
scheduleTime: '',
editMode: false,
};
case 'CLEAR_EDITOR':
return {
...state,
emailBody: '',
emailSubject: '',
scheduleDate: '',
scheduleTime: '',
emailRecipients: [],
editMode: false,
schedulingId: '',
emailId: null,
errorRetrievingData: false,
formComplete: false,
};
case 'CLEAR_ERROR':
return {
...state,
errorRetrievingData: false,
formComplete: false,
};
case 'SET_EDIT_MODE':
return {
...state,
editMode: action.payload,
};
case 'PATCH_SCHEDULED_EMAIL':
return state;
case 'PATCH_START':
return {
...state,
isLoading: true,
};
case 'PATCH_COMPLETE':
return {
...state,
isLoading: false,
errorRetrievingData: false,
formComplete: true,
...action.payload,
};
case 'PATCH_FAILURE':
return {
...state,
isLoading: false,
errorRetrievingData: true,
formComplete: false,
};
case 'POST_BULK_EMAIL':
return state;
case 'POST_START':
return {
...state,
isLoading: true,
};
case 'POST_COMPLETE':
return {
...state,
isLoading: false,
errorRetrievingData: false,
formComplete: true,
...action.payload,
};
case 'POST_FAILURE':
return {
...state,
isLoading: false,
errorRetrievingData: true,
formComplete: false,
};
default:
return state;
}
}
export const editorInitialState = {
emailBody: '',
emailSubject: '',
scheduleDate: '',
scheduleTime: '',
emailRecipients: [],
editMode: false,
schedulingId: '',
emailId: null,
isLoading: false,
errorRetrievingData: false,
formComplete: false,
};
export default editorReducer;

View File

@@ -0,0 +1,35 @@
import { initializeMockApp } from '../../../../../setupTest';
import { editorInitialState, editorReducer } from '../reducer';
describe('editorReducer', () => {
const testState = editorInitialState;
beforeAll(async () => {
await initializeMockApp();
});
it('does not remove present data from slice when EDITOR_ON_CHANGE action dispatched', () => {
const newEditorState = {
emailBody: 'test',
};
const returnedState = editorReducer(testState, { type: 'EDITOR_ON_CHANGE', payload: newEditorState });
const finalState = {
...testState,
emailBody: 'test',
};
expect(returnedState).toEqual(finalState);
});
it('it copies full editor state when COPY_TO_EDITOR action dispatched', () => {
const newEditorState = {
emailBody: 'test',
emailSubject: 'test',
emailRecipients: ['test'],
};
const finalState = {
...testState,
...newEditorState,
};
const returnedState = editorReducer(testState, { type: 'COPY_TO_EDITOR', payload: newEditorState });
expect(returnedState).toEqual(finalState);
});
});

View File

@@ -0,0 +1,53 @@
import {
patchScheduledEmail,
patchScheduledEmailComplete,
patchScheduledEmailError,
patchScheduledEmailStart,
postBulkEmail,
postBulkEmailComplete,
postBulkEmailError,
postBulkEmailStart,
} from './actions';
import { patchScheduledBulkEmailInstructorTask, postBulkEmailInstructorTask } from './api';
export function postBulkEmailThunk(emailData, courseId) {
return async (dispatch) => {
dispatch(postBulkEmail());
dispatch(postBulkEmailStart());
function onComplete(data) {
dispatch(postBulkEmailComplete());
return data;
}
function onError(error) {
dispatch(postBulkEmailError());
return error;
}
try {
const data = await postBulkEmailInstructorTask(emailData, courseId);
return onComplete(data);
} catch (error) {
return onError(error);
}
};
}
export function editScheduledEmailThunk(emailData, courseId, schedulingId) {
return async (dispatch) => {
dispatch(patchScheduledEmail());
dispatch(patchScheduledEmailStart());
function onComplete(data) {
dispatch(patchScheduledEmailComplete());
return data;
}
function onError(error) {
dispatch(patchScheduledEmailError());
return error;
}
try {
const data = await patchScheduledBulkEmailInstructorTask(emailData, courseId, schedulingId);
return onComplete(data);
} catch (error) {
return onError(error);
}
};
}

View File

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

View File

@@ -0,0 +1,119 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
/* BulkEmailForm.jsx Messages */
bulkEmailSubmitButtonDefault: {
id: 'bulk.email.submit.button.default',
defaultMessage: 'Send email',
},
bulkEmailSubmitButtonSchedule: {
id: 'bulk.email.submit.button.schedule',
defaultMessage: 'Schedule Email',
},
bulkEmailSubmitButtonPending: {
id: 'bulk.email.submit.button.pending',
defaultMessage: 'Submitting',
},
bulkEmailSubmitButtonComplete: {
id: 'bulk.email.submit.button.send.complete',
defaultMessage: 'Email Created',
},
bulkEmailSubmitButtonError: {
id: 'bulk.email.submit.button.error',
defaultMessage: 'Error',
},
bulkEmailSubmitButtonCompleteSchedule: {
id: 'bulk.email.submit.button.schedule.complete',
defaultMessage: 'Scheduling Done',
},
bulkEmailTaskAlertRecipients: {
id: 'bulk.email.task.alert.recipients',
defaultMessage: 'You are sending an email message with the subject {subject} to the following recipients:',
description: 'A warning shown to the user after submitting the email, to confirm the email recipients.',
},
bulkEmailToolLabel: {
id: 'bulk.email.tool.label',
defaultMessage: 'Email',
description: 'Tool label. Describes the function of the tool (to send email).',
},
bulkEmailSubjectLabel: {
id: 'bulk.email.subject.label',
defaultMessage: 'Subject',
description: 'Email subject line input label. Meant to have colon or equivilant punctuation.',
},
bulkEmailFormSubjectError: {
id: 'bulk.email.form.subject.error',
defaultMessage: 'A subject is required',
description: 'An Error message located under the subject line. Visible only on failure.',
},
bulkEmailBodyLabel: {
id: 'bulk.email.body.label',
defaultMessage: 'Body',
description: 'Email Body label. Meant to have colon or equivilant punctuation.',
},
bulkEmailFormBodyError: {
id: 'bulk.email.form.body.error',
defaultMessage: 'The message cannot be blank',
description: 'An error message located under the body editor. Visible only on failure.',
},
bulkEmailInstructionsProofreading: {
id: 'bulk.email.instructions.proofreading',
defaultMessage: 'We recommend sending learners no more than one email message per week. Before you send your email, review the text carefully and send it to yourself first, so that you can preview the formatting and make sure embedded images and links work correctly.',
description: 'A set of instructions to give users a heads up about the formatting of the email they are about to send',
},
bulkEmailInstructionsCaution: { id: 'bulk.email.instructions.caution', defaultMessage: 'Caution!' },
bulkEmailInstructionsCautionMessage: {
id: 'bulk.email.instructions.caution.message.new.email',
defaultMessage:
' When you select Send Email, you are creating a new email message that is added to the queue for sending, and cannot be cancelled.',
description: 'A warning about how emails are sent out to users',
},
bulkEmailFormScheduleBox: {
id: 'bulk.email.form.scheduleBox',
defaultMessage: 'Schedule this email for a future date',
description: 'Checkbox to schedule sending the email at a later date',
},
bulkEmailSendEmailButton: {
id: 'bulk.email.send.email.button',
defaultMessage: 'Send Email',
description: 'Schedule/Send email button',
},
bulkEmailFormError: {
id: 'bulk.email.form.error',
defaultMessage: 'An error occured while attempting to send the email.',
description: 'An Error message located under the submit button for the email form. Visible only on a failure.',
},
bulkEmailFormSuccess: {
id: 'bilk.email.form.success',
defaultMessage: 'Email successfully created',
},
bulkEmailFormScheduledSuccess: {
id: 'bulk.email.form.scheduled.success',
defaultMessage: 'Email successfully scheduled',
},
bulkEmailSubmitButtonReschedule: {
id: 'bulk.email.submit.button.reschedule',
defaultMessage: 'Reschedule Email',
},
bulkEmailTaskAlertEditingDate: {
id: 'bulk.email.task.alert.editing',
defaultMessage: 'You are editing a scheduled email to be sent on: {dateTime}',
description: 'This alert pops up before submitting when editing an email that has already been scheduled',
},
bulkEmailTaskAlertEditingSubject: {
id: 'bulk.email.task.alert.subject',
defaultMessage: 'with the subject: {subject}',
},
bulkEmailTaskAlertEditingTo: {
id: 'bulk.email.task.alert.to',
defaultMessage: 'to recipients:',
},
bulkEmailTaskAlertEditingWarning: {
id: 'bulk.email.task.alert.warning',
defaultMessage: 'This will not create a new scheduled email task and instead overwrite the one currently selected. Do you want to overwrite this scheduled email?',
description: 'This alert pops up before submitting when editing an email that has already been scheduled',
},
});
export default messages;

View File

@@ -2,64 +2,172 @@
* @jest-environment jsdom
*/
import React from 'react';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import {
render, screen, cleanup, act, fireEvent,
render, screen, cleanup, fireEvent, initializeMockApp, getConfig,
} from '../../../../setupTest';
import BulkEmailForm from '..';
import { postBulkEmail } from '../api';
import * as bulkEmailFormApi from '../data/api';
import { BulkEmailContext, BulkEmailProvider } from '../../bulk-email-context';
import { formatDate } from '../../../../utils/formatDateAndTime';
import cohortFactory from '../data/__factories__/bulkEmailFormCohort.factory';
jest.mock('../../text-editor/TextEditor');
jest.mock('../api', () => ({
__esModule: true,
postBulkEmail: jest.fn(() => ({ status: 200 })),
}));
const appendMock = jest.spyOn(FormData.prototype, 'append');
const dispatchMock = jest.fn();
const tomorrow = new Date();
tomorrow.setDate(new Date().getDate() + 1);
function renderBulkEmailForm() {
const { cohorts } = cohortFactory.build();
return (
<BulkEmailProvider>
<BulkEmailForm courseId="test" cohorts={cohorts} />
</BulkEmailProvider>
);
}
function renderBulkEmailFormContext(value) {
return (
<BulkEmailContext.Provider value={[value, dispatchMock]}>
<BulkEmailForm courseId="test" />
</BulkEmailContext.Provider>
);
}
describe('bulk-email-form', () => {
beforeAll(async () => {
await initializeMockApp();
});
beforeEach(() => jest.resetModules());
afterEach(cleanup);
afterEach(() => cleanup());
test('it renders', () => {
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
expect(screen.getByText('Submit')).toBeTruthy();
render(renderBulkEmailForm());
expect(screen.getByText('Send email')).toBeTruthy();
});
test('it shows a warning when clicking submit', async () => {
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
fireEvent.click(screen.getByText('Submit'));
render(renderBulkEmailForm());
fireEvent.click(screen.getByText('Send email'));
const warning = await screen.findByText('CAUTION!', { exact: false });
expect(warning).toBeTruthy();
});
test('Prevent form POST if invalid', async () => {
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
fireEvent.click(screen.getByText('Submit'));
render(renderBulkEmailForm());
fireEvent.click(screen.getByText('Send email'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('At least one recipient is required', { exact: false })).toBeInTheDocument();
expect(await screen.findByText('A subject is required')).toBeInTheDocument();
});
test('Shows complete message on completed POST', async () => {
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onPost().reply(200, {
course_id: 'test',
success: true,
});
render(renderBulkEmailForm());
fireEvent.click(screen.getByRole('checkbox', { name: 'Myself' }));
expect(screen.getByRole('checkbox', { name: 'Myself' })).toBeChecked();
fireEvent.change(screen.getByRole('textbox', { name: 'Subject:' }), { target: { value: 'test subject' } });
fireEvent.click(screen.getByText('Submit'));
fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'test subject' } });
fireEvent.change(screen.getByTestId('textEditor'), { target: { value: 'test body' } });
fireEvent.click(screen.getByText('Send email'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('Submitting')).toBeInTheDocument();
expect(await screen.findByText('A task to send the emails has been successfully created!')).toBeInTheDocument();
expect(await screen.findByText('Email Created')).toBeInTheDocument();
});
test('Shows Error on failed POST', async () => {
postBulkEmail.mockImplementation(() => {
throw Error('api-response-error');
});
await act(async () => {
render(<BulkEmailForm courseId="test-course-id" editorRef={jest.fn()} />);
const subjectLine = screen.getByRole('textbox', { name: 'Subject:' });
const recipient = screen.getByRole('checkbox', { name: 'Myself' });
fireEvent.click(recipient);
fireEvent.change(subjectLine, { target: { value: 'test subject' } });
fireEvent.click(screen.getByText('Submit'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('Error')).toBeInTheDocument();
});
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/courses/test/instructor/api/send_email`).reply(500);
render(renderBulkEmailForm());
const subjectLine = screen.getByRole('textbox', { name: 'Subject' });
const recipient = screen.getByRole('checkbox', { name: 'Myself' });
fireEvent.click(recipient);
fireEvent.change(subjectLine, { target: { value: 'test subject' } });
fireEvent.change(screen.getByTestId('textEditor'), { target: { value: 'test body' } });
fireEvent.click(screen.getByText('Send email'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /continue/i }));
expect(await screen.findByText('An error occured while attempting to send the email.')).toBeInTheDocument();
});
test('Checking "All Learners" disables each learner group', async () => {
render(renderBulkEmailForm());
fireEvent.click(screen.getByRole('checkbox', { name: 'All Learners' }));
const verifiedLearners = screen.getByRole('checkbox', { name: 'Learners in the verified certificate track' });
const auditLearners = screen.getByRole('checkbox', { name: 'Learners in the audit track' });
const { cohorts } = cohortFactory.build();
cohorts.forEach(cohort => expect(screen.getByRole('checkbox', { name: `Cohort: ${cohort}` })).toBeDisabled());
expect(verifiedLearners).toBeDisabled();
expect(auditLearners).toBeDisabled();
});
test('Shows scheduling form when checkbox is checked and submit is changed', async () => {
render(renderBulkEmailForm());
const scheduleCheckbox = screen.getByText('Schedule this email for a future date');
fireEvent.click(scheduleCheckbox);
expect(screen.getByText('Send time'));
expect(screen.getByText('Send date'));
expect(screen.getByText('Schedule Email'));
});
test('Prevents sending email when scheduling inputs are empty', async () => {
render(renderBulkEmailForm());
const scheduleCheckbox = screen.getByText('Schedule this email for a future date');
fireEvent.click(scheduleCheckbox);
const submitButton = await screen.findByText('Schedule Email');
fireEvent.click(submitButton);
const continueButton = await screen.findByRole('button', { name: /continue/i });
fireEvent.click(continueButton);
expect(screen.getByText('Date and time cannot be blank, and must be a date in the future'));
});
test('Adds scheduling data to POST requests when schedule is selected', async () => {
const postBulkEmailInstructorTask = jest.spyOn(bulkEmailFormApi, 'postBulkEmailInstructorTask');
render(renderBulkEmailForm());
fireEvent.click(screen.getByRole('checkbox', { name: 'Myself' }));
fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'test subject' } });
fireEvent.change(screen.getByTestId('textEditor'), { target: { value: 'test body' } });
const scheduleCheckbox = screen.getByText('Schedule this email for a future date');
fireEvent.click(scheduleCheckbox);
const submitButton = screen.getByText('Schedule Email');
const scheduleDate = screen.getByTestId('scheduleDate');
const scheduleTime = screen.getByTestId('scheduleTime');
fireEvent.change(scheduleDate, { target: { value: formatDate(tomorrow) } });
fireEvent.change(scheduleTime, { target: { value: '10:00' } });
fireEvent.click(submitButton);
const continueButton = await screen.findByRole('button', { name: /continue/i });
fireEvent.click(continueButton);
expect(appendMock).toHaveBeenCalledWith('schedule', expect.stringContaining(formatDate(tomorrow)));
expect(postBulkEmailInstructorTask).toHaveBeenCalledWith(expect.any(FormData), expect.stringContaining('test'));
});
test('will PATCH instead of POST when in edit mode', async () => {
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onPatch().reply(200);
render(
renderBulkEmailFormContext({
editor: {
editMode: true,
emailBody: 'test',
emailSubject: 'test',
emailRecipients: ['test'],
scheduleDate: formatDate(tomorrow),
scheduleTime: '10:00',
schedulingId: 1,
emailId: 1,
isLoading: false,
errorRetrievingData: false,
},
}),
);
const submitButton = screen.getByText('Reschedule Email');
fireEvent.click(submitButton);
expect(
await screen.findByText(
'This will not create a new scheduled email task and instead overwrite the one currently selected. Do you want to overwrite this scheduled email?',
),
).toBeInTheDocument();
const continueButton = await screen.findByRole('button', { name: /continue/i });
fireEvent.click(continueButton);
expect(dispatchMock).toHaveBeenCalled();
});
});

View File

@@ -1,29 +1,26 @@
/* eslint-disable react/no-unstable-nested-components */
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Icon, Modal, StatefulButton,
Button, Collapsible, Icon,
} from '@edx/paragon';
import { SpinnerSimple } from '@edx/paragon/icons';
import messages from './messages';
import { getSentEmailHistory } from './data/api';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
import ViewEmailModal from './ViewEmailModal';
export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
function BulkEmailContentHistory({ intl }) {
const { courseId } = useParams();
const BUTTON_STATE = {
DEFAULT: 'default',
PENDING: 'pending',
COMPLETE: 'complete',
};
const [emailHistoryData, setEmailHistoryData] = useState();
const [errorRetrievingData, setErrorRetrievingData] = useState(false);
const [showHistoricalEmailContentTable, setShowHistoricalEmailContentTable] = useState(false);
const [isMessageModalOpen, setIsMessageModalOpen] = useState(false);
const [messageContent, setMessageContent] = useState();
const [buttonState, setButtonState] = useState(BUTTON_STATE.DEFAULT);
/**
* Async function that makes a REST API call to retrieve historical email message data sent by the bulk course email
@@ -32,7 +29,6 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
async function fetchSentEmailHistoryData() {
setErrorRetrievingData(false);
setShowHistoricalEmailContentTable(false);
setButtonState(BUTTON_STATE.PENDING);
let data = null;
try {
@@ -47,7 +43,6 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
}
setShowHistoricalEmailContentTable(true);
setButtonState(BUTTON_STATE.COMPLETE);
}
/**
@@ -57,7 +52,7 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
* display bug in the table.
*/
function transformDataForTable() {
let tableData = {};
let tableData = [];
if (emailHistoryData) {
tableData = emailHistoryData.map((item) => ({
...item,
@@ -78,58 +73,6 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
setIsMessageModalOpen(true);
};
/**
* Renders a modal that will display the contents of a single historical email message sent via the bulk course email
* tool to a user.
*/
const renderMessageModal = () => (
<div>
<Modal
open={isMessageModalOpen}
title=""
body={(
<div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageSubject)}</p>
<p className="pl-2">{messageContent.subject}</p>
</div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageSentBy)}</p>
<p className="pl-2">{messageContent.requester}</p>
</div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageTimeSent)}</p>
<p className="pl-2">{messageContent.created}</p>
</div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageSentTo)}</p>
<p className="pl-2">{messageContent.sent_to}</p>
</div>
<hr className="py-2" />
<div>
<p>{intl.formatMessage(messages.modalMessageBody)}</p>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: messageContent.email.html_message }} />
</div>
</div>
)}
onClose={() => setIsMessageModalOpen(false)}
buttons={[
<Button onClick={() => {
copyTextToEditor(messageContent.email.html_message);
setIsMessageModalOpen(false);
}}
>
<FormattedMessage
id="bulk.email.tool.copy.message.button"
defaultMessage="Copy to editor"
/>
</Button>,
]}
/>
</div>
);
const tableColumns = [
{
Header: `${intl.formatMessage(messages.emailHistoryTableColumnHeaderSubject)}`,
@@ -168,6 +111,7 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
Cell: ({ row }) => (
<Button variant="link" className="px-1" onClick={() => onViewMessageClick(tableData[row.index])}>
{intl.formatMessage(messages.buttonViewMessage)}
<span className="sr-only">&nbsp;{row.index}</span>
</Button>
),
},
@@ -176,40 +120,36 @@ export function BulkEmailContentHistory({ intl, copyTextToEditor }) {
return (
<div>
<div>{messageContent && renderMessageModal()}</div>
{messageContent && (
<ViewEmailModal
messageContent={messageContent}
isOpen={isMessageModalOpen}
setModalOpen={setIsMessageModalOpen}
/>
)}
<div>
<p>{intl.formatMessage(messages.emailHistoryTableSectionButtonHeader)}</p>
<StatefulButton
className="btn btn-outline-primary mb-2"
variant="outline-primary"
type="submit"
onClick={async () => {
await fetchSentEmailHistoryData();
}}
labels={{
default: `${intl.formatMessage(messages.emailHistoryTableSectionButton)}`,
pending: `${intl.formatMessage(messages.emailHistoryTableSectionButton)}`,
complete: `${intl.formatMessage(messages.emailHistoryTableSectionButton)}`,
}}
icons={{
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
}}
disabledStates={['error']}
state={buttonState}
<Collapsible
styling="card"
title={intl.formatMessage(messages.emailHistoryTableSectionButton)}
className="mb-3"
// eslint-disable-next-line react/jsx-no-bind
onOpen={fetchSentEmailHistoryData}
>
{intl.formatMessage(messages.emailHistoryTableSectionButton)}
</StatefulButton>
{showHistoricalEmailContentTable && (
<BulkEmailTaskManagerTable
errorRetrievingData={errorRetrievingData}
tableData={transformDataForTable()}
tableDescription={intl.formatMessage(messages.emailHistoryTableViewMessageInstructions)}
alertWarningMessage={intl.formatMessage(messages.noEmailData)}
alertErrorMessage={intl.formatMessage(messages.errorFetchingEmailHistoryData)}
columns={tableColumns}
additionalColumns={additionalColumns()}
/>
)}
{showHistoricalEmailContentTable ? (
<BulkEmailTaskManagerTable
errorRetrievingData={errorRetrievingData}
tableData={transformDataForTable()}
tableDescription={intl.formatMessage(messages.emailHistoryTableViewMessageInstructions)}
alertWarningMessage={intl.formatMessage(messages.noEmailData)}
alertErrorMessage={intl.formatMessage(messages.errorFetchingEmailHistoryData)}
columns={tableColumns}
additionalColumns={additionalColumns()}
/>
) : (
<Icon src={SpinnerSimple} className="icon-spin mx-auto" />
)}
</Collapsible>
</div>
</div>
);
@@ -220,7 +160,6 @@ BulkEmailContentHistory.propTypes = {
row: PropTypes.shape({
index: PropTypes.number,
}),
copyTextToEditor: PropTypes.func.isRequired,
};
BulkEmailContentHistory.defaultProps = {

View File

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

@@ -7,7 +7,7 @@ import messages from './messages';
import useInterval from '../../../utils/useInterval';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
export function BulkEmailPendingTasks({ intl }) {
function BulkEmailPendingTasks({ intl }) {
const { courseId } = useParams();
const [instructorTaskData, setInstructorTaskData] = useState();

View File

@@ -0,0 +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 { FormattedMessage } from '@edx/frontend-platform/i18n';
export default function BulkEmailPendingTasksAlert(props) {
const { courseId } = props;
return (
<Alert variant="warning" icon={WarningFilled}>
<FormattedMessage
id="bulk.email.pending.tasks.description.one"
defaultMessage="To view all pending tasks, including email, visit&nbsp;"
/>
<Hyperlink
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor#view-course-info`}
target="_blank"
isInline
showLaunchIcon={false}
>
<FormattedMessage
id="bulk.email.pending.tasks.link"
defaultMessage="Course Info"
/>
</Hyperlink>
<FormattedMessage
id="bulk.email.pending.tasks.description.two"
defaultMessage="&nbsp;in the Instructor Dashboard."
/>
</Alert>
);
}
BulkEmailPendingTasksAlert.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -2,25 +2,21 @@ import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, StatefulButton } from '@edx/paragon';
import { Icon, Collapsible } from '@edx/paragon';
import { SpinnerSimple } from '@edx/paragon/icons';
import { getEmailTaskHistory } from './data/api';
import messages from './messages';
import BulkEmailTaskManagerTable from './BulkEmailHistoryTable';
export function BulkEmailTaskHistory({ intl }) {
const { courseId } = useParams();
const BUTTON_STATE = {
DEFAULT: 'default',
PENDING: 'pending',
COMPLETE: 'complete',
};
import './bulkEmailTaskHistory.scss';
const [emailTaskHistoryData, setEmailTaskHistoryData] = useState();
function BulkEmailTaskHistory({ intl }) {
const { courseId } = useParams();
const [emailTaskHistoryData, setEmailTaskHistoryData] = useState([]);
const [showHistoricalTaskContentTable, setShowHistoricalTaskContentTable] = useState(false);
const [errorRetrievingData, setErrorRetrievingData] = useState(false);
const [buttonState, setButtonState] = useState(BUTTON_STATE.DEFAULT);
/**
* Async function that makes a REST API call to retrieve historical bulk email (Instructor) task data for display
@@ -29,7 +25,6 @@ export function BulkEmailTaskHistory({ intl }) {
async function fetchEmailTaskHistoryData() {
setErrorRetrievingData(false);
setShowHistoricalTaskContentTable(false);
setButtonState(BUTTON_STATE.PENDING);
let data = null;
try {
@@ -44,7 +39,6 @@ export function BulkEmailTaskHistory({ intl }) {
}
setShowHistoricalTaskContentTable(true);
setButtonState(BUTTON_STATE.COMPLETE);
}
const tableColumns = [
@@ -87,38 +81,29 @@ export function BulkEmailTaskHistory({ intl }) {
];
return (
<div>
<div className="pb-4.5">
<div>
<p>
{intl.formatMessage(messages.emailTaskHistoryTableSectionButtonHeader)}
</p>
<StatefulButton
className="btn btn-outline-primary mb-2"
variant="outline-primary"
type="submit"
onClick={async () => { await fetchEmailTaskHistoryData(); }}
labels={{
default: `${intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}`,
pending: `${intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}`,
complete: `${intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}`,
}}
icons={{
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
}}
disabledStates={['error']}
state={buttonState}
<Collapsible
styling="card"
title={intl.formatMessage(messages.emailTaskHistoryTableSectionButton)}
// eslint-disable-next-line react/jsx-no-bind
onOpen={fetchEmailTaskHistoryData}
>
{intl.formatMessage(messages.emailHistoryTableSectionButton)}
</StatefulButton>
{showHistoricalTaskContentTable && (
<BulkEmailTaskManagerTable
errorRetrievingData={errorRetrievingData}
tableData={emailTaskHistoryData}
alertWarningMessage={intl.formatMessage(messages.noTaskHistoryData)}
alertErrorMessage={intl.formatMessage(messages.errorFetchingTaskHistoryData)}
columns={tableColumns}
/>
)}
{showHistoricalTaskContentTable ? (
<BulkEmailTaskManagerTable
errorRetrievingData={errorRetrievingData}
tableData={emailTaskHistoryData}
alertWarningMessage={intl.formatMessage(messages.noTaskHistoryData)}
alertErrorMessage={intl.formatMessage(messages.errorFetchingTaskHistoryData)}
columns={tableColumns}
/>
) : (
<Icon src={SpinnerSimple} className="icon-spin mx-auto" />
)}
</Collapsible>
</div>
</div>
);

View File

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

View File

@@ -0,0 +1,80 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Button, Modal } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { BulkEmailContext } from '../bulk-email-context';
import { copyToEditor } from '../bulk-email-form/data/actions';
function ViewEmailModal({
intl, messageContent, isOpen, setModalOpen,
}) {
const [, dispatch] = useContext(BulkEmailContext);
return (
<div>
<Modal
open={isOpen}
title=""
body={(
<div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageSubject)}</p>
<p className="pl-2">{messageContent.subject}</p>
</div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageSentBy)}</p>
<p className="pl-2">{messageContent.requester}</p>
</div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageTimeSent)}</p>
<p className="pl-2">{messageContent.created}</p>
</div>
<div className="d-flex flex-row">
<p>{intl.formatMessage(messages.modalMessageSentTo)}</p>
<p className="pl-2">{messageContent.sent_to}</p>
</div>
<hr className="py-2" />
<div>
<p>{intl.formatMessage(messages.modalMessageBody)}</p>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: messageContent.email.html_message }} />
</div>
</div>
)}
onClose={() => setModalOpen(false)}
buttons={[
<Button
onClick={() => {
dispatch(
copyToEditor({
emailBody: messageContent.email.html_message,
emailSubject: messageContent.subject,
}),
);
setModalOpen(false);
}}
>
<FormattedMessage id="bulk.email.tool.copy.message.button" defaultMessage="Copy to editor" />
</Button>,
]}
/>
</div>
);
}
ViewEmailModal.propTypes = {
intl: intlShape.isRequired,
messageContent: PropTypes.shape({
subject: PropTypes.string,
requester: PropTypes.string,
created: PropTypes.string,
email: PropTypes.shape({
html_message: PropTypes.string,
}).isRequired,
sent_to: PropTypes.string,
}).isRequired,
isOpen: PropTypes.bool.isRequired,
setModalOpen: PropTypes.func.isRequired,
};
export default injectIntl(ViewEmailModal);

View File

@@ -0,0 +1,203 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/no-unstable-nested-components */
import React, {
useCallback, useContext, useState, useEffect,
} from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Alert, DataTable, Icon, IconButton, useToggle,
} from '@edx/paragon';
import {
Delete, Info, Visibility, Edit,
} from '@edx/paragon/icons';
import { useParams } from 'react-router-dom';
import { BulkEmailContext } from '../../bulk-email-context';
import { deleteScheduledEmailThunk, getScheduledBulkEmailThunk } from './data/thunks';
import messages from './messages';
import ViewEmailModal from '../ViewEmailModal';
import { copyToEditor } from '../../bulk-email-form/data/actions';
import TaskAlertModal from '../../task-alert-modal';
import { formatDate, formatTime } from '../../../../utils/formatDateAndTime';
function flattenScheduledEmailsArray(emails) {
return emails.map((email) => ({
schedulingId: email.id,
emailId: email.courseEmail.id,
task: email.task,
taskDue: new Date(email.taskDue).toLocaleString(),
taskDueUTC: email.taskDue,
...email.courseEmail,
targets: email.courseEmail.targets.join(', '),
}));
}
function BulkEmailScheduledEmailsTable({ intl }) {
const { courseId } = useParams();
const [{ scheduledEmailsTable }, dispatch] = useContext(BulkEmailContext);
const [tableData, setTableData] = useState([]);
const [viewModal, setViewModal] = useState({
isOpen: false,
messageContent: {},
});
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle();
const [currentTask, setCurrentTask] = useState({});
useEffect(() => {
setTableData(flattenScheduledEmailsArray(scheduledEmailsTable.results));
}, [scheduledEmailsTable.results]);
const fetchTableData = useCallback((args) => {
dispatch(getScheduledBulkEmailThunk(courseId, args.pageIndex + 1));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleViewEmail = (row) => {
setViewModal({
isOpen: true,
messageContent: {
subject: row.original.subject,
requester: row.original.sender,
created: row.original.taskDue,
email: {
html_message: row.original.htmlMessage,
},
sent_to: row.original.targets,
},
});
};
if (scheduledEmailsTable.errorRetrievingData) {
return (
<div className="pb-4">
<Alert variant="danger" icon={Info}>
<Alert.Heading>{intl.formatMessage(messages.bulkEmailScheduledEmailsTableErrorHeader)}</Alert.Heading>
<p>{intl.formatMessage(messages.bulkEmailScheduledEmailsTableError)}</p>
</Alert>
</div>
);
}
const handleDeleteEmail = async () => {
const {
row, pageIndex, page, previousPage,
} = currentTask;
await dispatch(deleteScheduledEmailThunk(courseId, row.original.schedulingId));
if (page.length === 1 && pageIndex !== 0) {
previousPage();
} else {
dispatch(getScheduledBulkEmailThunk(courseId, pageIndex + 1));
}
};
const handleEditEmail = (row) => {
const {
original: {
htmlMessage: emailBody, subject: emailSubject, taskDueUTC, targets, schedulingId, emailId,
},
} = row;
const dateTime = new Date(taskDueUTC);
const emailRecipients = targets.replaceAll('-', ':').split(', ');
const scheduleDate = formatDate(dateTime);
const scheduleTime = formatTime(dateTime);
dispatch(
copyToEditor({
emailId,
emailBody,
emailSubject,
emailRecipients,
scheduleDate,
scheduleTime,
schedulingId,
editMode: true,
}),
);
};
return (
<>
<TaskAlertModal
isOpen={isConfirmModalOpen}
close={(event) => {
closeConfirmModal();
if (event.target.name === 'continue') {
handleDeleteEmail();
}
}}
alertMessage={intl.formatMessage(
messages.bulkEmailScheduledEmailsTableConfirmDelete,
{ date: currentTask?.row?.original?.taskDue ?? '' },
)}
/>
{viewModal.isOpen && (
<ViewEmailModal
isOpen={viewModal.isOpen}
setModalOpen={(open) => setViewModal({ isOpen: open })}
messageContent={viewModal.messageContent}
/>
)}
<div className="pb-4">
<DataTable
isLoading={scheduledEmailsTable.isLoading}
itemCount={scheduledEmailsTable.count}
pageCount={scheduledEmailsTable.numPages}
data={tableData}
isPaginated
manualPagination
fetchData={fetchTableData}
initialState={{
pageSize: 10,
pageIndex: 0,
}}
columns={[
{
Header: intl.formatMessage(messages.bulkEmailScheduledEmailsTableSendDate),
accessor: 'taskDue',
},
{
Header: intl.formatMessage(messages.bulkEmailScheduledEmailsTableSendTo),
accessor: 'targets',
},
{
Header: intl.formatMessage(messages.bulkEmailScheduledEmailsTableSubject),
accessor: 'subject',
},
{
Header: intl.formatMessage(messages.bulkEmailScheduledEmailsTableAuthor),
accessor: 'sender',
},
]}
additionalColumns={[
{
id: 'action',
Header: 'Action',
Cell: ({
row, state, page, previousPage,
}) => (
<>
<IconButton src={Visibility} iconAs={Icon} alt="View" onClick={() => handleViewEmail(row)} />
<IconButton
src={Delete}
iconAs={Icon}
alt="Delete"
onClick={() => {
setCurrentTask({
row, pageIndex: state.pageIndex, page, previousPage,
});
openConfirmModal();
}}
/>
<IconButton src={Edit} iconAs={Icon} alt="Edit" onClick={() => handleEditEmail(row)} />
</>
),
},
]}
/>
</div>
</>
);
}
BulkEmailScheduledEmailsTable.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BulkEmailScheduledEmailsTable);

View File

@@ -0,0 +1,31 @@
export const fetchScheduledEmails = () => ({
type: 'FETCH_SCHEDULED_EMAILS',
});
export const fetchScheduledEmailsStart = () => ({
type: 'FETCH_START',
});
export const fetchScheduledEmailsComplete = (payload) => ({
type: 'FETCH_COMPLETE',
payload,
});
export const fetchScheduledEmailsError = () => ({
type: 'FETCH_FAILURE',
});
export const deleteScheduledEmail = () => ({
type: 'DELETE_SCHEDULED_EMAIL',
});
export const deleteScheduledEmailStart = () => ({
type: 'DELETE_START',
});
export const deleteScheduledEmailComplete = () => ({
type: 'DELETE_COMPLETE',
});
export const deleteScheduledEmailError = () => ({
type: 'DELETE_FAILURE',
});

View File

@@ -0,0 +1,28 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
// eslint-disable-next-line import/prefer-default-export
export async function getScheduledBulkEmailIntructorTaskData(courseId, page = 1) {
const endpointUrl = `${
getConfig().LMS_BASE_URL
}/api/instructor_task/v1/schedules/${courseId}/bulk_email/?page=${page}`;
try {
const { data } = await getAuthenticatedHttpClient().get(endpointUrl);
return camelCaseObject(data);
} catch (error) {
logError(error);
throw new Error(error);
}
}
export async function deleteScheduledBulkEmailInstructorTask(courseId, scheduleId) {
const endpointUrl = `${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/${courseId}/bulk_email/${scheduleId}`;
try {
const { status } = await getAuthenticatedHttpClient().delete(endpointUrl);
return status;
} catch (error) {
logError(error);
throw new Error(error);
}
}

View File

@@ -0,0 +1,60 @@
export function scheduledEmailsTableReducer(state, action) {
switch (action.type) {
case 'FETCH_SCHEDULED_EMAILS':
return state;
case 'FETCH_START':
return {
...state,
isLoading: true,
};
case 'FETCH_COMPLETE':
return {
...state,
isLoading: false,
errorRetrievingData: false,
...action.payload,
};
case 'FETCH_FAILURE':
return {
...state,
isLoading: false,
errorRetrievingData: true,
};
case 'DELETE_SCHEDULED_EMAIL':
return state;
case 'DELETE_START':
return {
...state,
isLoading: true,
};
case 'DELETE_COMPLETE':
return {
...state,
isLoading: false,
errorRetrievingData: false,
...action.payload,
};
case 'DELETE_FAILURE':
return {
...state,
isLoading: false,
errorRetrievingData: true,
};
default:
return state;
}
}
export const scheduledEmailsTableInitialState = {
results: [],
isLoading: false,
errorRetrievingData: false,
count: 0,
numPages: 0,
currentPage: 0,
start: 0,
previous: null,
next: null,
};
export default scheduledEmailsTableReducer;

View File

@@ -0,0 +1,63 @@
import { initializeMockApp } from '../../../../../../setupTest';
import { scheduledEmailsTableReducer } from '../reducer';
describe('scheduledEmailsTableReducer', () => {
const testState = {
scheduledEmails: [],
isLoading: false,
errorRetrievingData: false,
};
beforeAll(async () => {
await initializeMockApp();
});
it('does not change state on FETCH_SCHEDULED_EMAILS', () => {
expect(scheduledEmailsTableReducer(testState, { type: 'FETCH_SCHEDULED_EMAILS' })).toEqual(testState);
});
it('sets loading state on FETCH_START', () => {
const finalState = {
...testState,
isLoading: true,
};
const returnedState = scheduledEmailsTableReducer(testState, { type: 'FETCH_START' });
expect(returnedState).toEqual(finalState);
});
it('adds payload on FETCH_COMPLETE', () => {
const finalState = {
...testState,
additionalField: true,
isLoading: false,
};
const returnedState = scheduledEmailsTableReducer(testState, { type: 'FETCH_COMPLETE', payload: { additionalField: true } });
expect(returnedState).toEqual(finalState);
expect(returnedState.isLoading).toEqual(false);
expect(returnedState.errorRetrievingData).toEqual(false);
});
it('sets Error to true when FETCH_FAILURE action dispatched', () => {
const finalState = {
...testState,
isLoading: false,
errorRetrievingData: true,
};
const returnedState = scheduledEmailsTableReducer(testState, { type: 'FETCH_FAILURE' });
expect(returnedState).toEqual(finalState);
});
it('properly sets state on DELETE_COMPLETE', () => {
const finalState = {
...testState,
};
const returnedState = scheduledEmailsTableReducer(testState, { type: 'DELETE_COMPLETE' });
expect(returnedState).toEqual(finalState);
expect(returnedState.isLoading).toEqual(false);
expect(returnedState.errorRetrievingData).toEqual(false);
});
it('sets Error when DELETE_FAILURE action dispatched', () => {
const finalState = {
...testState,
isLoading: false,
errorRetrievingData: true,
};
const returnedState = scheduledEmailsTableReducer(testState, { type: 'DELETE_FAILURE' });
expect(returnedState).toEqual(finalState);
});
});

View File

@@ -0,0 +1,53 @@
import {
deleteScheduledEmail,
deleteScheduledEmailComplete,
deleteScheduledEmailError,
deleteScheduledEmailStart,
fetchScheduledEmails,
fetchScheduledEmailsComplete,
fetchScheduledEmailsError,
fetchScheduledEmailsStart,
} from './actions';
import { deleteScheduledBulkEmailInstructorTask, getScheduledBulkEmailIntructorTaskData } from './api';
export function getScheduledBulkEmailThunk(courseId, page) {
return async (dispatch) => {
dispatch(fetchScheduledEmails());
dispatch(fetchScheduledEmailsStart());
function onComplete(data) {
dispatch(fetchScheduledEmailsComplete(data));
return data;
}
function onError(error) {
dispatch(fetchScheduledEmailsError());
return error;
}
try {
const data = await getScheduledBulkEmailIntructorTaskData(courseId, page);
return onComplete(data);
} catch (error) {
return onError(error);
}
};
}
export function deleteScheduledEmailThunk(courseId, emailIndex) {
return async (dispatch) => {
dispatch(deleteScheduledEmail());
dispatch(deleteScheduledEmailStart());
function onComplete(data) {
dispatch(deleteScheduledEmailComplete(data));
return data;
}
function onError(error) {
dispatch(deleteScheduledEmailError());
return error;
}
try {
const status = await deleteScheduledBulkEmailInstructorTask(courseId, emailIndex);
return onComplete(status);
} catch (error) {
return onError(error);
}
};
}

View File

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

View File

@@ -0,0 +1,37 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
/* BulkEmailScheduledEmailsTable.jsx Messages */
bulkEmailScheduledEmailsTableErrorHeader: {
id: 'bulk.email.scheduled.emails.table.error.header',
defaultMessage: 'Error',
},
bulkEmailScheduledEmailsTableError: {
id: 'bulk.email.scheduled.emails.table.error',
defaultMessage: 'An error occured while retrieving scheduled email information. Please try again later.',
description: 'An error message that shows if the app is unable to display scheduled emails in the table',
},
bulkEmailScheduledEmailsTableSendDate: {
id: 'bulk.email.scheduled.emails.table.sendDate',
defaultMessage: 'Send date',
},
bulkEmailScheduledEmailsTableSendTo: {
id: 'bulk.email.scheduled.emails.table.sendTo',
defaultMessage: 'Send to',
},
bulkEmailScheduledEmailsTableSubject: {
id: 'bulk.email.scheduled.emails.table.subject',
defaultMessage: 'Subject',
},
bulkEmailScheduledEmailsTableAuthor: {
id: 'bulk.email.scheduled.emails.table.Author',
defaultMessage: 'Author',
},
bulkEmailScheduledEmailsTableConfirmDelete: {
id: 'bulk.email.scheduled.emails.table.confirm.delete',
defaultMessage: 'You are deleting an email scheduled to be sent on {date}. The email will not be sent and the scheduling canceled. Are you sure?',
},
});
export default messages;

View File

@@ -0,0 +1,141 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import {
render, screen, cleanup, fireEvent, initializeMockApp,
} from '../../../../../setupTest';
import { BulkEmailProvider } from '../../../bulk-email-context';
import BulkEmailScheduledEmailsTable from '..';
import scheduledEmailsFactory from './__factories__/scheduledEmails.factory';
import * as actions from '../../../bulk-email-form/data/actions';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn().mockReturnValue({ courseId: 'test-id' }),
}));
function renderBulkEmailScheduledEmailsTable() {
return (
<BulkEmailProvider>
<BulkEmailScheduledEmailsTable />
</BulkEmailProvider>
);
}
describe('BulkEmailScheduledEmailsTable', () => {
beforeAll(async () => {
await initializeMockApp();
});
afterEach(() => {
cleanup();
Factory.resetAll();
});
it('properly renders scheduled emails', async () => {
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
.reply(200, scheduledEmailsFactory.build(1));
render(renderBulkEmailScheduledEmailsTable());
expect(await screen.findByText('learners')).toBeTruthy();
expect(await screen.findByText('subject')).toBeTruthy();
expect(await screen.findByText('edx')).toBeTruthy();
expect(await screen.findByLabelText('View')).toBeTruthy();
});
it('shows an error when the fetch fails', async () => {
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
.reply(500, { response: 500 });
render(renderBulkEmailScheduledEmailsTable());
expect(
await screen.findByText('An error occured while retrieving scheduled email information. Please try again later.'),
).toBeTruthy();
});
it('pops up the modal when viewing an email', async () => {
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
.reply(200, scheduledEmailsFactory.build(1));
render(renderBulkEmailScheduledEmailsTable());
fireEvent.click(await screen.findByLabelText('View'));
expect(await screen.findByText('Subject:')).toBeTruthy();
expect(await screen.findByText('Sent by:')).toBeTruthy();
expect(await screen.findByText('Time sent:')).toBeTruthy();
expect(await screen.findByText('Sent to:')).toBeTruthy();
expect(await screen.findByText('Message:')).toBeTruthy();
});
it('properly formats data for editing mode', async () => {
const editorObj = {
editMode: true,
emailId: 1,
emailBody: '<p>body</p>',
emailSubject: 'subject',
emailRecipients: ['learners'],
scheduleDate: '2022-04-27',
scheduleTime: '00:00',
schedulingId: 1,
};
jest.spyOn(actions, 'copyToEditor');
jest.spyOn(actions, 'setEditMode');
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
.reply(200, scheduledEmailsFactory.build(1));
render(renderBulkEmailScheduledEmailsTable());
fireEvent.click(await screen.findByLabelText('Edit'));
expect(actions.copyToEditor).toHaveBeenCalledWith(editorObj);
});
it('pops up alert on delete pressed', async () => {
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
.replyOnce(200, scheduledEmailsFactory.build(1))
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
.replyOnce(200, {
next: null,
previous: null,
count: 0,
num_pages: 1,
current_page: 1,
start: 0,
results: [],
})
.onDelete(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/1`)
.reply(204, []);
render(renderBulkEmailScheduledEmailsTable());
fireEvent.click(await screen.findByLabelText('Delete'));
expect(await screen.findByText('Caution')).toBeInTheDocument();
});
it('Deletes an email when clicking continue on warning', async () => {
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
.replyOnce(200, scheduledEmailsFactory.build(1))
.onGet(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/?page=1`)
.replyOnce(200, {
next: null,
previous: null,
count: 0,
num_pages: 1,
current_page: 1,
start: 0,
results: [],
})
.onDelete(`${getConfig().LMS_BASE_URL}/api/instructor_task/v1/schedules/test-id/bulk_email/1`)
.reply(204, []);
render(renderBulkEmailScheduledEmailsTable());
fireEvent.click(await screen.findByLabelText('Delete'));
expect(await screen.findByText('Caution')).toBeInTheDocument();
fireEvent.click(await screen.findByText('Continue'));
expect(await screen.findByText('No results found')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,19 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('emailDataFactory')
.sequence('id')
.attrs({
subject: 'subject',
html_message: '<p>body</p>',
text_message: 'body',
course_id: 'course-v1:edX+DemoX+Demo_Course',
to_option: '',
sender: 'edx',
targets: ['learners'],
});
export default Factory.define('courseEmailFactory')
.sequence('id')
.attr('course_email', Factory.build('emailDataFactory'))
.sequence('task')
.attr('task_due', '2022-04-27T00:00:00Z');

View File

@@ -0,0 +1,19 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import './courseEmail.factory';
export default Factory.define('scheduledEmailFactory')
.attr(
'next',
'http://localhost:18000/api/instructor_task/v1/schedules/course-v1:edX+DemoX+Demo_Course/bulk_email/?page=$2',
)
.attr('previous', 'null')
.option('count', '1')
.attr('current_page', 1)
.attr('start', 0)
.attr('results', ['count'], (count) => {
const emails = [];
for (let i = 1; i <= count; i++) {
emails.push(Factory.build('courseEmailFactory'));
}
return emails;
});

View File

@@ -0,0 +1,4 @@
// Apply side scroll for table that overflows
div.collapsible-body {
overflow: auto;
}

View File

@@ -60,7 +60,7 @@ const messages = defineMessages({
},
emailHistoryTableSectionButtonHeader: {
id: 'bulk.email.content.history.table.button.header',
defaultMessage: 'To see the content of previously sent emails, click this button:',
defaultMessage: 'View the content of previously sent emails',
},
emailHistoryTableSectionButton: {
id: 'bulk.email.content.history.table.button',
@@ -69,7 +69,7 @@ const messages = defineMessages({
/* BulkEmailTaskManager.jsx messages */
pendingTasksHeader: {
id: 'bulk.email.pending.tasks.header',
defaultMessage: 'Pending Tasks',
defaultMessage: 'Pending tasks has moved',
},
emailTaskHistoryHeader: {
id: 'bulk.email.email.task.history.header',
@@ -91,7 +91,7 @@ const messages = defineMessages({
/* BulkEmailTaskHistory.jsx messages */
emailTaskHistoryTableSectionButtonHeader: {
id: 'bulk.email.task.history.table.button.header',
defaultMessage: 'To see the status for all email tasks submitted for this course, click this button:',
defaultMessage: 'View the status for all email tasks created for this course',
},
emailTaskHistoryTableSectionButton: {
id: 'bulk.email.task.history.table.button',
@@ -142,6 +142,10 @@ const messages = defineMessages({
id: 'bulk.email.task.history.table.column.header.taskProgress',
defaultMessage: 'Task Progress',
},
scheduledEmailsTableHeader: {
id: 'bulk.email.scheduled.emails.table.header',
defaultMessage: 'Scheduled emails',
},
});
export default messages;

View File

@@ -3,8 +3,9 @@
*/
import React from 'react';
import {
render, screen, fireEvent, cleanup, act,
render, screen, fireEvent, cleanup, act, initializeMockApp,
} from '../../../../setupTest';
import { BulkEmailProvider } from '../../bulk-email-context';
import BulkEmailContentHistory from '../BulkEmailContentHistory';
import { getSentEmailHistory } from '../data/api';
import buildEmailContentHistoryData from '../data/__factories__/emailContentHistory.factory';
@@ -14,14 +15,25 @@ jest.mock('../data/api', () => ({
getSentEmailHistory: jest.fn(() => {}),
}));
function renderBulkEmailContentHistory() {
return (
<BulkEmailProvider>
<BulkEmailContentHistory courseId="test-course-id" />
</BulkEmailProvider>
);
}
describe('BulkEmailContentHistory component', () => {
beforeEach(() => jest.resetModules());
beforeAll(async () => {
await initializeMockApp();
});
afterEach(cleanup);
test('renders correctly', async () => {
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
render(renderBulkEmailContentHistory());
const tableDescription = await screen.findByText(
'To see the content of previously sent emails, click this button:',
'View the content of previously sent emails',
);
expect(tableDescription).toBeTruthy();
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
@@ -33,7 +45,7 @@ describe('BulkEmailContentHistory component', () => {
const emailHistoryData = buildEmailContentHistoryData(1);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
render(renderBulkEmailContentHistory());
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
@@ -59,6 +71,8 @@ describe('BulkEmailContentHistory component', () => {
expect(await screen.findByText(email.requester)).toBeTruthy();
expect(await screen.findByText(email.sent_to.join(', '))).toBeTruthy();
expect(await screen.findByText(email.email.subject)).toBeTruthy();
// verify screen reader only <span />
expect(await screen.findByText('0')).toHaveClass('sr-only');
expect(await screen.findAllByText('View Message')).toBeTruthy();
});
});
@@ -68,7 +82,7 @@ describe('BulkEmailContentHistory component', () => {
const emailHistoryData = buildEmailContentHistoryData(1);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
render(renderBulkEmailContentHistory());
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
@@ -101,7 +115,7 @@ describe('BulkEmailContentHistory component', () => {
const emailHistoryData = buildEmailContentHistoryData(0);
getSentEmailHistory.mockImplementation(() => emailHistoryData);
// render the component
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
render(renderBulkEmailContentHistory());
// press the `show sent email history` button to initiate data retrieval
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);
@@ -117,7 +131,7 @@ describe('BulkEmailContentHistory component', () => {
throw new Error();
});
// render the component
render(<BulkEmailContentHistory copyTextToEditor={jest.fn()} />);
render(renderBulkEmailContentHistory());
// press the `show sent email history` button to initiate data retrieval
const showEmailContentHistoryButton = await screen.findByText('Show Sent Email History');
fireEvent.click(showEmailContentHistoryButton);

View File

@@ -3,7 +3,7 @@
*/
import React from 'react';
import {
render, screen, cleanup, act,
render, screen, cleanup, act, initializeMockApp,
} from '../../../../setupTest';
import BulkEmailPendingTasks from '../BulkEmailPendingTasks';
import { getInstructorTasks } from '../data/api';
@@ -16,6 +16,9 @@ jest.mock('../data/api', () => ({
describe('BulkEmailPendingTasks component', () => {
beforeEach(() => jest.resetModules());
beforeAll(async () => {
await initializeMockApp();
});
afterEach(cleanup);
test('renders correctly', async () => {
@@ -27,68 +30,72 @@ describe('BulkEmailPendingTasks component', () => {
});
test('renders a table when running Instructor Task data is returned', async () => {
await act(async () => {
jest.useFakeTimers();
jest.useFakeTimers();
const pendingInstructorTaskData = buildPendingInstructorTaskData(1);
getInstructorTasks.mockImplementation(() => pendingInstructorTaskData);
const pendingInstructorTaskData = buildPendingInstructorTaskData(1);
getInstructorTasks.mockImplementation(() => pendingInstructorTaskData);
act(() => {
render(<BulkEmailPendingTasks />);
});
act(() => {
// fast forward time by 31 seconds for the API call to be made to retrieve pending tasks
jest.advanceTimersByTime(31000);
// verify component structure
const tableDescription = await screen.findByText(
'Email actions run in the background. The status for any active tasks - including email tasks - appears in '
+ 'the table below.',
);
expect(tableDescription).toBeTruthy();
// verify table structure
expect(await screen.findByText('Task Type')).toBeTruthy();
expect(await screen.findByText('Task Inputs')).toBeTruthy();
expect(await screen.findByText('Task Id')).toBeTruthy();
expect(await screen.findByText('Requester')).toBeTruthy();
expect(await screen.findByText('Submitted')).toBeTruthy();
expect(await screen.findByText('Duration (seconds)')).toBeTruthy();
expect(await screen.findByText('State')).toBeTruthy();
expect(await screen.findByText('Status')).toBeTruthy();
expect(await screen.findByText('Task Progress')).toBeTruthy();
expect(await screen.findAllByText('Showing 1 of 1')).toBeTruthy();
// verification of table contents
const { tasks } = pendingInstructorTaskData;
const task = tasks[0];
expect(await screen.findByText(task.created)).toBeTruthy();
expect(await screen.findByText(task.duration_sec)).toBeTruthy();
expect(await screen.findByText(task.requester)).toBeTruthy();
expect(await screen.findByText(task.status)).toBeTruthy();
expect(await screen.findByText(task.task_id)).toBeTruthy();
expect(await screen.findByText(task.task_input)).toBeTruthy();
expect(await screen.findByText(task.task_message)).toBeTruthy();
expect(await screen.findByText(task.task_state)).toBeTruthy();
expect(await screen.findByText(task.task_type)).toBeTruthy();
});
// verify component structure
const tableDescription = await screen.findByText(
'Email actions run in the background. The status for any active tasks - including email tasks - appears in '
+ 'the table below.',
);
expect(tableDescription).toBeTruthy();
// verify table structure
expect(await screen.findByText('Task Type')).toBeTruthy();
expect(await screen.findByText('Task Inputs')).toBeTruthy();
expect(await screen.findByText('Task Id')).toBeTruthy();
expect(await screen.findByText('Requester')).toBeTruthy();
expect(await screen.findByText('Submitted')).toBeTruthy();
expect(await screen.findByText('Duration (seconds)')).toBeTruthy();
expect(await screen.findByText('State')).toBeTruthy();
expect(await screen.findByText('Status')).toBeTruthy();
expect(await screen.findByText('Task Progress')).toBeTruthy();
expect(await screen.findAllByText('Showing 1 of 1.')).toBeTruthy();
// verification of table contents
const { tasks } = pendingInstructorTaskData;
const task = tasks[0];
expect(await screen.findByText(task.created)).toBeTruthy();
expect(await screen.findByText(task.duration_sec)).toBeTruthy();
expect(await screen.findByText(task.requester)).toBeTruthy();
expect(await screen.findByText(task.status)).toBeTruthy();
expect(await screen.findByText(task.task_id)).toBeTruthy();
expect(await screen.findByText(task.task_input)).toBeTruthy();
expect(await screen.findByText(task.task_message)).toBeTruthy();
expect(await screen.findByText(task.task_state)).toBeTruthy();
expect(await screen.findByText(task.task_type)).toBeTruthy();
});
test('renders an error Alert if an error occurs retrieving data', async () => {
await act(async () => {
jest.useFakeTimers();
jest.useFakeTimers();
getInstructorTasks.mockImplementation(() => {
throw new Error();
});
getInstructorTasks.mockImplementation(() => {
throw new Error();
});
act(() => {
render(<BulkEmailPendingTasks />);
});
act(() => {
// fast forward time by 31 seconds for the API call to be made to retrieve pending tasks
jest.advanceTimersByTime(31000);
const alertMessage = await screen.findByText(
'Error fetching running task data. This request will be retried automatically.',
);
expect(alertMessage).toBeTruthy();
});
const alertMessage = await screen.findByText(
'Error fetching running task data. This request will be retried automatically.',
);
expect(alertMessage).toBeTruthy();
});
});

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

@@ -3,7 +3,7 @@
*/
import React from 'react';
import {
render, screen, fireEvent, cleanup, act,
render, screen, fireEvent, cleanup, act, initializeMockApp,
} from '../../../../setupTest';
import BulkEmailTaskHistory from '../BulkEmailTaskHistory';
import { getEmailTaskHistory } from '../data/api';
@@ -16,12 +16,15 @@ jest.mock('../data/api', () => ({
describe('BulkEmailTaskHistory component', () => {
beforeEach(() => jest.resetModules());
beforeAll(async () => {
await initializeMockApp();
});
afterEach(cleanup);
test('renders correctly ', async () => {
render(<BulkEmailTaskHistory />);
const tableDescription = await screen.findByText(
'To see the status for all email tasks submitted for this course, click this button:',
'View the status for all email tasks created for this course',
);
expect(tableDescription).toBeTruthy();
const showEmailTaskHistoryButton = await screen.findByText('Show Email Task History');
@@ -48,7 +51,7 @@ 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 of 1.')).toBeTruthy();
// verification of row contents
const { tasks } = taskHistoryData;
const task = tasks[0];

View File

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

View File

@@ -35,7 +35,15 @@ function TaskAlertModal(props) {
id="bulk.email.form.recipients.Contine"
defaultMessage="Continue"
description="Continue button for the task alert"
/>
>
{ // FormattedMessage wraps the translated string in a <span/> by default. This was
// causing strange click event target issues in safari. To solve this, we want to
// wrap the string in a fragment instead of a span, so that the whole button considered
// a "button" target, and not a "span inside a button"
// eslint-disable-next-line react/jsx-no-useless-fragment
msg => <>{msg}</>
}
</FormattedMessage>
</Button>
</ActionRow>
)}

View File

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

View File

@@ -3,7 +3,10 @@
*/
import React from 'react';
import { Factory } from 'rosie';
import { render, screen, cleanup } from '../../../setupTest';
import { camelCaseObject } from '@edx/frontend-platform';
import {
render, screen, cleanup, initializeMockApp,
} from '../../../setupTest';
import BulkEmailTool from '../BulkEmailTool';
import { CourseMetadataContext } from '../../page-container/PageContainer';
import '../../page-container/data/__factories__/cohort.factory';
@@ -23,6 +26,9 @@ jest.mock('react-router-dom', () => ({
describe('BulkEmailTool', () => {
beforeEach(() => jest.resetModules());
beforeAll(async () => {
await initializeMockApp();
});
afterEach(cleanup);
/**
@@ -31,15 +37,15 @@ describe('BulkEmailTool', () => {
*/
function buildCourseMetadata(cohortData, courseData) {
const {
org, number, title, tabs, is_staff: isStaff,
} = courseData;
org, number, title, tabs, originalUserIsStaff,
} = camelCaseObject(courseData);
const { cohorts } = cohortData;
return {
org,
number,
title,
isStaff,
originalUserIsStaff,
tabs: [...tabs],
cohorts: cohorts.map(({ name }) => name),
};
@@ -73,7 +79,7 @@ describe('BulkEmailTool', () => {
test('BulkEmailTool renders error page on no staff user', async () => {
const cohorts = { cohorts: [] };
const courseInfo = Factory.build('courseMetadata', { is_staff: false });
const courseInfo = Factory.build('courseMetadata', { original_user_is_staff: false });
const courseMetadata = buildCourseMetadata(cohorts, courseInfo);
renderBulkEmailTool(courseMetadata);
// verify error page is displayed for user without staff permissions

View File

@@ -22,7 +22,7 @@ import contentCss from 'tinymce/skins/content/default/content.css';
export default function TextEditor(props) {
const {
onChange, onKeyUp, onInit, disabled,
onChange, onKeyUp, onInit, disabled, value,
} = props;
return (
@@ -33,17 +33,21 @@ export default function TextEditor(props) {
height: 600,
branding: false,
menubar: 'edit view insert format table tools',
plugins: 'advlist code link lists table image language codesample',
plugins: 'advlist code link lists table image codesample',
toolbar:
'formatselect fontselect bold italic underline forecolor | codesample bullist numlist alignleft aligncenter alignright alignjustify indent | blockquote link image code | language',
'formatselect fontselect bold italic underline forecolor | codesample bullist numlist alignleft aligncenter alignright alignjustify indent | blockquote link image code ',
skin: false,
content_css: false,
content_style: `${contentUiCss.toString()}\n${contentCss.toString()}`,
extended_valid_elements: 'span[lang|id] -span',
block_unsupported_drop: false,
image_advtab: true,
name: 'emailBody',
relative_urls: false,
remove_script_host: false,
}}
onChange={onChange}
onEditorChange={onChange}
value={value}
onKeyUp={onKeyUp}
onInit={onInit}
disabled={disabled}
@@ -56,6 +60,7 @@ TextEditor.defaultProps = {
onKeyUp: () => {},
onInit: () => {},
disabled: false,
value: '',
};
TextEditor.propTypes = {
@@ -63,4 +68,5 @@ TextEditor.propTypes = {
onKeyUp: PropTypes.func,
onInit: PropTypes.func,
disabled: PropTypes.bool,
value: PropTypes.string,
};

View File

@@ -1,23 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
function MockTinyMCE({ onInit }) {
const mockedEditor = {
getContent: () => 'test body',
};
onInit({}, mockedEditor);
return <div />;
/**
* We represent tinyMCE here as a textarea, because tinyMCE has no support for testing
* with jest, so we need to mock it out. This is not ideal, but since the TextEditor
* component is really just a wrapper, we're not too concerned about unit testing.
*/
function MockTinyMCE({ onChange }) {
return <textarea data-testid="textEditor" onChange={onChange} />;
}
MockTinyMCE.propTypes = {
onInit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
export default function TextEditor({ onInit }) {
return <MockTinyMCE onInit={onInit} />;
export default function TextEditor({ onChange }) {
return <MockTinyMCE onChange={onChange} />;
}
TextEditor.propTypes = {
onInit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};

View File

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

View File

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

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

@@ -8,10 +8,12 @@ export default function NavigationTabs(props) {
return (
<div className="py-4">
<Nav>
<Nav variant="tabs" defaultActiveKey="Instructor">
{tabData && tabData.map(tab => (
<Nav.Item key={tab.tab_id}>
<Nav.Link eventKey={tab.url} href={tab.url} className="mx-3 py-2">{tab.title}</Nav.Link>
<Nav.Link eventKey={tab.title} href={tab.url}>
{tab.title}
</Nav.Link>
</Nav.Item>
))}
</Nav>

View File

@@ -31,7 +31,7 @@ export default function PageContainer(props) {
org: '',
number: '',
title: '',
isStaff: false,
originalUserIsStaff: false,
tabs: [],
cohorts: [],
});
@@ -39,7 +39,7 @@ export default function PageContainer(props) {
}
const {
org, number, title, tabs, is_staff: isStaff,
org, number, title, tabs, originalUserIsStaff,
} = metadataResponse;
const { cohorts } = cohortsResponse;
@@ -47,27 +47,30 @@ export default function PageContainer(props) {
org,
number,
title,
isStaff,
originalUserIsStaff,
tabs: [...tabs],
cohorts: cohorts.map(({ name }) => name),
});
}
fetchCourseMetadata();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (courseMetadata) {
return (
<CourseMetadataContext.Provider value={courseMetadata}>
<>
<Header
className="learning-header"
courseOrg={courseMetadata.org}
courseNumber={courseMetadata.number}
courseTitle={courseMetadata.title}
/>
{children}
<Footer />
</>
<Header
className="learning-header"
courseOrg={courseMetadata.org}
courseNumber={courseMetadata.number}
courseTitle={courseMetadata.title}
/>
<div className="pb-3 container">
<main>
{children}
</main>
</div>
<Footer />
</CourseMetadataContext.Provider>
);
}

View File

@@ -6,7 +6,7 @@ export default Factory.define('courseMetadata')
.option('host', 'http://localhost:18000')
.attrs({
is_staff: true,
original_user_is_staff: false,
original_user_is_staff: true,
number: 'DemoX',
org: 'edX',
title: 'Demonstration Course',

View File

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

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

View File

@@ -4,7 +4,7 @@
import React from 'react';
import { Factory } from 'rosie';
import {
act, cleanup, render, screen,
act, cleanup, initializeMockApp, render, screen,
} from '../../../setupTest';
import PageContainer from '../PageContainer';
@@ -26,6 +26,9 @@ jest.mock('react-router-dom', () => ({
describe('PageContainer', () => {
beforeEach(() => jest.resetModules());
beforeAll(async () => {
await initializeMockApp();
});
afterEach(cleanup);
test('PageContainer renders properly when given course metadata', async () => {

View File

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

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 { Helmet } from 'react-helmet';
import { Switch } from 'react-router-dom';
import appMessages from './i18n';
import messages from './i18n';
import './index.scss';
import BulkEmailTool from './components/bulk-email-tool';
@@ -20,15 +18,16 @@ 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>
<Switch>
<AuthenticatedPageRoute path="/courses/:courseId/bulk_email">
<PageContainer>
<BulkEmailTool />
</PageContainer>
</AuthenticatedPageRoute>
</Switch>
</AppProvider>,
document.getElementById('root'),
);
@@ -39,13 +38,16 @@ subscribe(APP_INIT_ERROR, (error) => {
});
initialize({
config: () => {
mergeConfig({
}, 'CommuncationsAppConfig');
handlers: {
config: () => {
mergeConfig(
{
// MICROBA-1505: Remove this when we remove the flag from config
SCHEDULE_EMAIL_SECTION: process.env.SCHEDULE_EMAIL_SECTION || null,
},
'CommunicationsAppConfig',
);
},
},
messages: [
appMessages,
headerMessages,
footerMessages,
],
messages,
});

View File

@@ -1,8 +1,7 @@
@import "@edx/brand/paragon/fonts.scss";
@import "@edx/brand/paragon/variables.scss";
@import "@edx/paragon/scss/core/core.scss";
@import "@edx/brand/paragon/overrides.scss";
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";

View File

@@ -2,17 +2,21 @@ import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom';
import React from 'react';
import PropTypes from 'prop-types';
import { render } from '@testing-library/react';
import { render as rtlRender } from '@testing-library/react';
import AppProvider from '@edx/frontend-platform/react/AppProvider';
import { configure as configureI18n } from '@edx/frontend-platform/i18n';
import { configure as configureLogging } from '@edx/frontend-platform/logging';
import { getConfig } from '@edx/frontend-platform';
import appMessages from './i18n';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { configure as configureLogging, MockLoggingService } from '@edx/frontend-platform/logging';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { configure as configureAuth, MockAuthService } from '@edx/frontend-platform/auth';
import messages from './i18n';
jest.mock('@edx/frontend-platform/react/hooks', () => ({
...jest.requireActual('@edx/frontend-platform/react/hooks'),
useTrackColorSchemeChoice: jest.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
@@ -24,46 +28,52 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({
userId: 'abc123',
username: 'MockUser',
roles: [],
administrator: false,
})),
export function initializeMockApp() {
mergeConfig({
// MICROBA-1505: Remove this when we remove the flag from config
SCHEDULE_EMAIL_SECTION: true,
authenticatedUser: {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
},
});
const loggingService = configureLogging(MockLoggingService, {
config: getConfig(),
});
const i18nService = configureI18n({
config: getConfig(),
loggingService,
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(),
}));
class MockLoggingService {
logInfo = jest.fn();
logError = jest.fn();
function render(ui, options) {
// eslint-disable-next-line react/prop-types
function Wrapper({ children }) {
return (
// eslint-disable-next-line react/jsx-filename-extension
<IntlProvider locale="en">
<AppProvider>{children}</AppProvider>
</IntlProvider>
);
}
return rtlRender(ui, { wrapper: Wrapper, ...options });
}
const loggingService = configureLogging(MockLoggingService, {
config: getConfig(),
});
configureI18n({
config: getConfig(),
loggingService,
messages: [appMessages],
});
function Wrapper({ children }) {
return (
// eslint-disable-next-line react/jsx-filename-extension
<AppProvider>{children}</AppProvider>
);
}
const renderWithProviders = (ui, options) => {
render(ui, { wrapper: Wrapper, ...options });
};
Wrapper.propTypes = {
children: PropTypes.node.isRequired,
};
// Re-export everything.
export * from '@testing-library/react';
// Override `render` method.
export { renderWithProviders as render };
export { render };

View File

@@ -0,0 +1,14 @@
const normalizeDigits = (value) => (value < 10 ? `0${value}` : value);
export const formatDate = (date) => {
const day = normalizeDigits(date.getDate());
const month = normalizeDigits(date.getMonth() + 1);
const year = date.getFullYear();
return `${year}-${month}-${day}`;
};
export const formatTime = (date) => {
const hours = normalizeDigits(date.getHours());
const mins = normalizeDigits(date.getMinutes());
return `${hours}:${mins}`;
};

View File

@@ -0,0 +1,52 @@
import { useMemo, useReducer } from 'react';
/**
* This helper function wraps the useReducer dispatch function to allow for invoking function calls
* when a state change is dispatched.
* @param {Function} dispatch useReducer's dispatch function.
* @returns a wrapped dispatch that execututes function actions.
*/
export function wrapAsync(dispatch) {
return (action) => {
if (typeof action === 'function') {
return action(dispatch);
}
return dispatch(action);
};
}
/**
* A utility function to combine reducers. this allows us to organize and create individual reducers
* for components with its own slice of the store. This function the returns a combined reducer to make
* dispatching easier amoungst multiple components. This function isnt used directly by this hook, but is a part of
* the ecosystem around it, and is meant to be used as a step before calling the hook.
* @param {Object} slices reducer functions to be combined.
* @returns a single reducer function
*/
export function combineReducers(slices) {
return (prevState, action) => Object.keys(slices).reduce(
(nextState, nextProp) => ({
...nextState,
[nextProp]: slices[nextProp](prevState[nextProp], action),
}),
prevState,
);
}
/**
* By default, the useReducer hook does not allow for async dispatches. This small
* hook takes the dispatch function from useReducer and wraps it to allow for the execution
* of functions that are invoked with the dispatch object. This makes it easier for us to perform
* async operations, or to execute multiple dispatches in a row using a single thunk.
* @param {Function} reducer a reducer function for the context state.
* @param {Object} initialState an initial state for the context store.
* @returns [state, asyncDispatch ]
*/
const useAsyncReducer = (reducer, initialState = null) => {
const [state, dispatch] = useReducer(reducer, initialState);
const asyncDispatch = useMemo(() => wrapAsync(dispatch), [dispatch]);
return [state, asyncDispatch];
};
export default useAsyncReducer;

View File

@@ -37,6 +37,7 @@ export default function useMobileResponsive(breakpoint) {
window.addEventListener('resize', checkForMobile);
// return this function here to clean up the event listener
return () => window.removeEventListener('resize', checkForMobile);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return isMobileWindow;
}

View File

@@ -8,16 +8,17 @@ import { useRef, useEffect } from 'react';
* and only allow one at a time.
* @param {function} callback The function to call once the delay ends
* @param {millisecond} delay time to delay function call
* @param {bool} cancel cancels the callback early if true
*/
export default function useTimeout(callback, delay) {
export default function useTimeout(callback, delay, cancel) {
const timeoutRef = useRef(null);
useEffect(() => {
const timeout = timeoutRef.current;
if (timeout) {
if (timeout || cancel) {
clearTimeout(timeout);
}
}, []);
}, [cancel]);
return () => {
if (timeoutRef.current) {