Compare commits

...

204 Commits

Author SHA1 Message Date
Alie Langston
8a5687bfe3 feat: gate learning assistant if learner is in exam 2023-11-13 14:36:49 -06:00
Leangseu Kim
c38d69f9db chore: update test 2023-11-09 14:19:28 -05:00
Leangseu Kim
18103bcf54 chore: make notification display by default 2023-11-09 14:19:28 -05:00
German
9698c4d4de fix: maxScore can be null (#1235) 2023-11-09 14:47:25 -03:00
Marcos
a70a26f2e5 Endpoint usage implementation for Courseware Search (WIP) (#1232)
* feat: Endpoint usage implementation for Courseware Search

Co-authored-by: Simon Chen <schen@edx.org>

* chore: Added tests and error case

* fix: remove console log

* fix: update tests

* fix: update tests

* fix: update variables

---------

Co-authored-by: Simon Chen <schen@edx.org>
Co-authored-by: German <germanolle@gmail.com>
2023-11-09 13:13:59 -03:00
Leangseu Kim
308f03cf3a chore: make notification trigger default to open for audit user 2023-11-08 15:54:09 -05:00
Stanislav
9a0cdc06c9 fix: Fix dropdown menu in breadcrumbs (#1190)
Co-authored-by: Stanislav Lunyachek <lunyachek@MacBook-Pro-M1.local>
2023-11-08 13:35:29 -05:00
Mashal Malik
a64f0e0406 refactor: updated README file to reflect template changes (#1230) 2023-10-31 19:12:30 +05:00
Alex
ce24a58c99 feat: Add courseware search results filter container (#1225) 2023-10-27 09:34:29 -05:00
Marcos
c257048d29 feat: Added CoursewareSearchResults UI component (#1224)
* feat: Added CoursewareSearchResults UI component

* fix: Added conditional for undefined case instead of null

* fix: Updated code to avoid mutation
2023-10-27 10:57:02 -03:00
David Nuon
7c9211073f Add CoursewareSearchForm component (#1214)
* feat: Add CoursewareSearchBar component

* fix: lint

* fix: Clarified component names and i18n description

* test: Add more tests

* fix: lint

* fix: Made props in CoursewareSearchForm optional
2023-10-24 13:32:26 -07:00
alangsto
040f1cb55b feat: upgrade learning assistant version (#1217) 2023-10-23 15:54:48 -04:00
Feanil Patel
00e7680c20 chore: Update to the new version of brand-openedx in the new scope. (#1216)
Part of https://github.com/openedx/axim-engineering/issues/23

This updates the `@edx/brand` alias to point to the `brand-openedx` package at
the `openedx` scope. This does not impact imports because this package is used
via an alias.
2023-10-20 13:21:00 -04:00
Marcos
cbb419c256 Added Courseware Search container popover UI (#1212)
* feat: Added Courseware Search container popover

* chore: Added unit tests for CoursewareSearch and CoursewareSearchToggle

* chore: Updated unit test for CourseTabsNavigation

* chore: Partial coverage on Courseware Search Hooks

* chore: Finished Courseware Search Hooks unit testing

* fix: Fixed an overlook that caused a conditional hook

* fix: Reduced bounce timeout on scroll/resize to 100ms

* chore: Updated snapshots

* chore: Moved @testing-library/react-hooks dep to DEV

* chore: Minor adjustments on unit tests

* chore: Fixed test issue
2023-10-20 11:33:41 -03:00
alangsto
12205de132 feat: upgrade learning assistant version (#1215) 2023-10-20 10:07:18 -04:00
Marcos
62465ec956 feat: added waffle flag state for Courseware Search (#1199) 2023-10-12 19:06:00 +00:00
Syed Ali Abbas Zaidi
165097d061 chore: bump frontend-platform (#1209) 2023-10-12 18:42:22 +05:00
Zachary Hancock
570cdb4b2a fix: exams lib bug patch (#1205) 2023-10-11 14:09:01 -04:00
Rafay
391ea08b20 fix: make progress graph respect course settings (#1194) 2023-10-11 11:28:10 -04:00
renovate[bot]
5604def491 chore(deps): update dependency @edx/frontend-build to v12.9.17 2023-10-04 21:51:00 +00:00
Syed Ali Abbas Zaidi
b788b969c3 feat: upgrade react router to v6 (#1128)
* feat: upgrade react router to v6

* fix: all test cases affected by react router upgrade

* refactor: fix navigations

* fix: test cases affectewd due to lib-special-exams

* refactor: improve code coverage
2023-10-04 17:34:53 -04:00
Zachary Hancock
b7a3d5640a fix: special exams version fix (#1196) 2023-09-27 13:12:20 -04:00
Zachary Hancock
3a21d8c807 feat: update exams library (#1188) 2023-09-25 13:46:29 -04:00
alangsto
81442bebe9 feat: update learning assistant version (#1195) 2023-09-21 14:44:58 -04:00
alangsto
168ed1e184 feat: upgrade learning assistant version (#1187) 2023-09-18 13:27:57 -04:00
Ben Warzeski
c8e32c3f46 feat: allow override of plugin.modal height (#1184) 2023-09-15 10:12:47 -04:00
Bilal Qamar
51dd90741b feat: update react & react-dom to v17 (#1127)
* feat: update react & react-dom to v17

* build: update paragon version

* refactor: updated edx packages

* refactor: updated react-unit-test-utils

* build: update lock file

* build: update lock file

* build: update lock file

* build: update lock file

* refactor: bumped frontend-lib-learning-assistant version

---------

Co-authored-by: mashal-m <mashal.malik@arbisoft.com>
2023-09-11 10:53:32 -04:00
Michael Roytman
f58d6d6d25 feat: add Segment event for rendering Xpert and bump frontend-lib-learning-assistant version (#1182)
In order to diagnose low usage rates, we're temporarily adding Segment events for rendering the chat and for closing the call-to-action message. This will help us determine whether Xpert is being successfully rendered and whether Xpert is being ignored by tracking whether learners close the call-to-action message at a high rate.

At a minimum, we expect to remove the Segment event for rendering the Xpert, because this will be a very noisy Segment event. We plan to leave it in for a few days just to accumulate data. We will evaluate whether to keep the call-to-action dismissal Segment event based on the data.

This commit adds the Segment event for rendering the Xpert. This commit also installs version 1.11.1 of frontend-lib-learning-assistant, which introduces a Segment event for for closing the call-to-action message.
2023-09-08 16:11:41 -04:00
alangsto
81a49bd755 feat: grant audit learners access (#1180) 2023-09-07 13:47:04 -04:00
Pierre Mailhot
2ae033160f fix: alert.start.long and alert.start.calendar (#1173)
They are currently using end, but since we are in the course-start-alert file it should be start instead.
2023-09-07 11:18:35 -04:00
alangsto
32bd3190a6 fix: prevent content tools from displaying over chat sidebar (#1179) 2023-08-31 17:09:16 -04:00
Artur Gaspar
645ac2cb5f fix: toggle notes visibility button state correctly (#1170) 2023-08-31 10:36:53 -04:00
Michael Roytman
ee80b24cba feat: install new version of frontend-lib-learning-assistant to conditionally render toggle elements (#1178)
This commit installs version 1.9.3 of @edx/frontend-lib-learning-assistant, which includes a UI tweak to conditionally render the Xpert toggle button and action message (call-to-action) only when the Xpert sidebar is closed.
2023-08-30 11:53:23 -04:00
alangsto
ee1d816cc8 feat: update frontend-lib-learning-assistant (#1177) 2023-08-30 08:48:22 -04:00
alangsto
e8ac2ffc7e feat: update frontend-lib-learning-assistant (#1176) 2023-08-29 17:01:38 -04:00
alangsto
62d3e95cc8 feat: update learning assistant version (#1175) 2023-08-29 15:47:02 -04:00
Michael Roytman
ce6771d7cc feat: install new version of frontend-lib-learning-assistant to add UI tweaks (#1174)
This commit installs version 1.8.0 of @edx/frontend-lib-learning-assistant, which includes a various UI tweaks.
2023-08-29 14:08:39 -04:00
alangsto
1dcde821b4 feat: upgrade learning assistant library (#1172) 2023-08-28 13:38:19 -04:00
Michael Roytman
694e3ed6d5 feat: install new version of frontend-lib-learning-assistant and add Privacy Policy URL to config (#1171)
This commit installs version 1.6.0 of @edx/frontend-lib-learning-assistant, which includes a new user disclosure feature. This commit also includes the Privacy Policy URL to the frontend-app-learning config, because the Xpert learning assistant uses this config value in the disclosure.
2023-08-28 10:44:27 -04:00
alangsto
ba843622c2 feat: update version of the learning assistant frontend (#1168) 2023-08-24 14:19:19 -04:00
Michael Roytman
2d29827e6b feat: install Xpert chatbot from frontend-lib-learning-assistant (#1166)
This commit installs the Xpert chatbot feature from the frontend-lib-learning-assistant repository into the frontend-lib-learning application.

This component is rendered by the Course component. The component is only rendered when a few conditions are satisfied.
2023-08-23 09:14:14 -04:00
sundasnoreen12
2b9b3db5d3 refactor: refactor code by creating 1 instance of header instead of 3 (#1164)
* refactor: refactor code by creating single instance of header instead of 3

* refactor: refactor courseStatus comparsion code

---------

Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-08-23 14:46:47 +05:00
Awais Ansari
2e90e214b4 fix: should not fetch courseTopics for lti provider (#1165)
* fix: should not fetch courseTopics for lti provider

* refactor: fetch course topics when provider is edx
2023-08-22 12:44:31 +05:00
alangsto
ea2d7ed839 feat: add config value for chat response url (#1163) 2023-08-18 08:52:25 -04:00
Mohamed Akram
5ee61904d5 fix: make nav buttons use links for accessibility (#1137) 2023-08-14 13:18:55 -04:00
Zachary Hancock
6232b0cb98 Revert "Revert "feat: update special exams lib (#1152)" (#1154)" (#1161)
This reverts commit 1871e491a7.

The original PR was reverted due to it's deploy coinciding with the app breaking. Turns out it was not the root cause.
2023-08-10 10:30:22 -04:00
Zachary Hancock
09542338a2 feat: rebuild package lock (#1160)
* feat: rebuild package lock

* feat: update paragon

* test: fix axios/jest incompatibility
2023-08-09 13:29:59 -04:00
renovate[bot]
c3d345e642 chore(deps): update dependency @edx/reactifex to v2.2.0 2023-08-09 04:24:16 +00:00
renovate[bot]
ec2bf60345 chore(deps): update dependency @edx/browserslist-config to v1.2.0 2023-08-09 00:02:00 +00:00
Zachary Hancock
b0c71e5291 fix: error when navigating exam units (#1157)
* fix: incorrect usage of useEffect callback caused intermittent react errors
2023-08-08 17:34:05 -04:00
renovate[bot]
dcd6847254 fix(deps): update dependency reselect to v4.1.8 2023-08-08 18:41:06 +00:00
renovate[bot]
d2df9241c3 chore(deps): update dependency @edx/frontend-build to v12.9.4 2023-08-08 15:56:08 +00:00
Zachary Hancock
1871e491a7 Revert "feat: update special exams lib (#1152)" (#1154)
This reverts commit 0c49658314.
2023-08-08 11:23:12 -04:00
renovate[bot]
03543c0af1 fix(deps): update dependency js-cookie to v3.0.5 2023-08-08 14:33:17 +00:00
Zachary Hancock
0c49658314 feat: update special exams lib (#1152) 2023-08-08 09:04:58 -04:00
Demid
2a1173584e fix: make iframe wrapper take all vieport width (#1094) 2023-07-31 12:21:56 -04:00
Omar Al-Ithawi
398330fa07 feat: include paragon in atlas pull (#1145)
This pull request is part of the [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) which is sparked by the [Translation Infrastructure update OEP-58](https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification).
2023-07-25 11:21:54 -04:00
renovate[bot]
f92fc8c3a5 fix(deps): update dependency @popperjs/core to v2.11.8 2023-07-25 14:01:38 +00:00
renovate[bot]
5e072949d6 chore(deps): update dependency @edx/frontend-build to v12.9.3 2023-07-25 11:25:32 +00:00
Rebecca Graber
2d132f114c feat: upgrade pact (#1141)
Upgrade pact to 11
2023-07-17 12:47:29 -04:00
alangsto
c73ef26d8e feat: add segment event for lti modal launch (#1140) 2023-07-13 13:56:25 -04:00
ayesha waris
97ca7fe6aa fix: sidebar state remains open for all users (#1139) 2023-07-13 15:30:16 +05:00
Ben Warzeski
e95a59c6c8 fix: modal hook name for unit iframe modal signal (#1138) 2023-07-10 15:26:56 -04:00
Peter Kulko
5f9c441cd2 fix: added Paragon translations (#1136) 2023-07-10 14:25:56 -04:00
Ben Warzeski
2e641ac6c9 Bw/unit splitup (#1134)
* refactor: break Unit component into smaller unit-tested parts

* feat: save scroll position on video fullscreen exit

* chore: remove swap file
2023-07-10 10:29:56 -04:00
alangsto
22937918ab feat: add component to iframe LTI launch (#1135) 2023-07-06 14:57:39 -04:00
ihor-romaniuk
714f5d452c fix: save scroll position on exit from video xblock fullscreen mode 2023-07-06 08:43:01 -04:00
ayesha waris
8ac9745261 fix: modifies sidebar state such that it remains open (#1131)
* fix: modifies sidebar state such that it remains open

* refactor: removed localstorage for discussions sideba
2023-06-27 14:05:46 +05:00
Zachary Hancock
340580cb41 chore: update exams lib (#1130) 2023-06-22 09:19:43 -04:00
Leangseu Kim
5a99ca5c91 fix: breadcrumb jump nav styling 2023-06-08 09:19:45 -04:00
Ben Warzeski
9943df49e4 feat: allow clipboard write to xblock iframes (#1117) 2023-06-06 10:09:33 -04:00
Jenkins
855474d406 chore(i18n): update translations 2023-06-04 17:09:52 -04:00
Ghassan Maslamani
a78496a3f6 fix: sync LMS_BASE_URL for bookmark API if changed
This change makes it possible to use the latest  LMS_BASE_API
  if it was changed because of dynamic config API, which is the
  default case of tutor.

  This changes closes openedx/wg-build-test-release/issues/270

   Fixes that are simlar to this
  - gradebook openedx/frontend-app-gradebook/pull/290
  - course authoring openedx/frontend-app-course-authoring/pull/389
2023-05-31 15:11:34 +01:00
Jansen Kantor
79b65dadca fix: gracefully handle 403 responses in tab loading (#1111) 2023-05-24 11:38:40 -04:00
Bilal Qamar
fc8f5d43e8 feat: upgraded to node v18, added .nvmrc and updated workflows (#1084)
* feat: upgraded to node v18, added .nvmrc and updated workflows

* refactor: updated packages

* refactor: updated packages

* refactor: updated packages

* fix: add workaround in json file

* build: remove jestEnv line from json

* refactor: updated packages

* refactor: updated packages

* refactor: updated packages

* refactor: updated packages

* refactor: updated packages

* refactor: updated packages

* fix: add workaround in json file

* build: remove jestEnv line from json

* refactor: updated packages

* refactor: updated packages

* refactor: updated packages

* refactor: updated packages

* refactor: updated packages

* refactor: updated jest & fixed failing tests

* refactor: updated lmsPact failing test cases

* refactor: updated frontend-build version

* Merge branch master of github.com:edx/frontend-app-learning into bilalqamar95/node-v18-upgrade

---------

Co-authored-by: mashal-m <mashal.malik@arbisoft.com>
2023-05-23 18:39:34 +05:00
Zachary Hancock
6232f40a74 feat: update special-exams-lib (#1113) 2023-05-16 15:08:46 -04:00
David Joy
bc0ff1ce65 chore: bumping frontend-platform version to get userId logging
frontend-platform has a new feature to include the userId (if it exists) when logging an error to the logging service.  We want that.
2023-05-16 09:44:29 -04:00
David Joy
5997b29cee fix: logging an error when unit iframe fails to load
Right now we log nothing to the logging service when a unit iframe fails to load.  The ErrorPage that’s shown isn’t using the ErrorBoundary, so we had no indication that something went wrong.  This solves the problem closer to the source where the error originates.
2023-05-16 09:44:29 -04:00
Omar Al-Ithawi
d2de0632cd feat: add experimental atlas to pull_translations (#1093)
This is an experimental off-by-defualt feature for moving the translation files ouside the repos.

Run `OPENEDX_ATLAS_PULL=true make translations` to use atlas to pull translations instead of transifex.

Refs: FC-12 OEP-58
2023-05-09 10:03:42 -04:00
Zachary Hancock
922cc2187a fix: update exams lib to fix download click bug (#1110) 2023-05-09 09:23:40 -04:00
Sagirov Eugeniy
d9539796b5 chore: update frontend-platform version to v4.2.0 2023-05-02 14:34:30 -03:00
alangsto
e0acb501eb chore: upgrade frontend-lib-special-exams version (#1107) 2023-04-26 08:52:03 -04:00
Asad Ali
a03ffe2724 fix: fix links under contenttools (#1096) 2023-04-26 13:42:16 +05:00
Jenkins
cbdf7ce064 chore(i18n): update translations 2023-04-23 17:09:46 -04:00
Zachary Hancock
7184e85b2b feat: update exams library (#1103) 2023-04-21 12:09:29 -04:00
Emad Rad
b5321d01e4 feat: Persian Language added to messages (#989)
feat: fa_IR added to transifex_langs

feat: Persian translations added

Co-authored-by: Leangseu Kim <lkim@edx.org>
Co-authored-by: leangseu-edx <83240113+leangseu-edx@users.noreply.github.com>
2023-04-18 14:03:06 -04:00
Yoiber
6c8ab1a4c9 chore(i18n): add more languages (#1063)
* chore(i18n): add more languages

* chore(i18n): Pylint fixed
2023-04-18 12:41:30 -04:00
Varsha
01f9d8f50b feat: fetch exam access token (#1083)
* feat: fetch exam access token

* build: update frontend lib special exams version
2023-04-17 14:59:05 -04:00
Zachary Hancock
764befd4bd feat: exams svc should not be enabled by default (#1100) 2023-04-12 10:36:21 -04:00
Zachary Hancock
7317c9424a feat: update special-exams lib (#1098) 2023-04-10 09:46:21 -04:00
alangsto
d897663b73 feat: upgrade special exams version and add required config values (#1097) 2023-04-06 09:55:06 -04:00
Muhammad Adeel Tajamul
2e4eb158f2 feat: added url param to open discussion sidebar (#1092) 2023-04-04 13:33:35 +05:00
Jenkins
35b229bd1b chore(i18n): update translations 2023-04-02 17:09:43 -04:00
Muhammad Adeel Tajamul
4ebd569792 feat: added open/close state of discussion sidebar in local storage (#1086) 2023-03-28 15:39:00 +05:00
lunyachek
52235ebc1c feat: create component to decode params 2023-03-27 14:54:42 -04:00
Jenkins
aa380e8619 chore(i18n): update translations 2023-03-26 17:09:41 -04:00
lunyachek
4cf0c7f4d7 feat: Add border for active tab in course navigation at Live page 2023-03-22 10:36:33 -04:00
alangsto
743650a99e chore: pin frontend lib special exams version (#1088) 2023-03-17 13:35:26 -04:00
Muhammad Adeel Tajamul
39d89bee9e fix: discussion sidebar loads very slow (#1081) 2023-03-13 05:40:23 +05:00
Jenkins
a601e431b2 chore(i18n): update translations 2023-03-12 17:09:40 -04:00
Muhammad Adeel Tajamul
7519bbe28e fix: copy link for discussion sidebar not working in chrome (#1079) 2023-03-10 06:01:24 +05:00
alangsto
4b90dcbfc3 feat: update special exams version (#1080) 2023-03-09 10:45:47 -05:00
Zachary Hancock
54cb52cb6d feat: update special-exams library (#1078) 2023-03-08 15:14:39 -05:00
renovate[bot]
6dbd3f49dd fix(deps): update dependency @edx/paragon to v20.28.4 2023-03-01 11:25:35 +00:00
renovate[bot]
678502bb40 fix(deps): update dependency @edx/brand to v1.2.0 2023-03-01 07:19:50 +00:00
renovate[bot]
bf77fc7ca1 fix(deps): update dependency query-string to v7.1.3 2023-03-01 02:08:11 +00:00
renovate[bot]
421a9a5d2b fix(deps): update dependency @edx/frontend-lib-special-exams to v2.2.1 2023-02-28 22:45:58 +00:00
Feanil Patel
dfe44cae56 build: Updating 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-28 10:37:18 -05:00
Feanil Patel
a88571dae8 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-28 10:37:18 -05:00
Feanil Patel
a4ea334692 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-28 10:37:18 -05:00
Adam Stankiewicz
97a1cb4ffc chore: upgrade @edx/frontend-platform to v3.4.1 (#1071)
* chore: upgrade @edx/frontend-platform to v3.4.0
* chore: upgrade to frontend-platform v3.4.1
2023-02-28 09:18:28 -05:00
Jenkins
5166bfe056 chore(i18n): update translations 2023-02-26 16:09:40 -05:00
Varsha
33e3765b19 build: add exams url to envs (#1066) 2023-02-22 11:40:24 -05:00
Jenkins
a13e7d7389 chore(i18n): update translations 2023-02-19 16:09:38 -05:00
Isaac Lee
a4ea1b54a4 fix: exams with no due date now display exam type (#1064)
* fix: exams with no due date now display exam type
2023-02-16 15:16:19 -05:00
Eugene Dyudyunov
cd430ebb5d fix: first section celebration
Fix the first section celebration modal showing logic.

On Nutmeg+ it's shown only after the page reload or after going directly
to the second section from the course home. Going through the course
with the Next/Previous buttons has no effect (which worked on Maple).

Notes:
- the weekly goal has the same showing logic, but I assume that is
correct behavior so no changes are added for it in this commit.
- showing a celebration modal for the first section completion when
going directly to the first unit of the second section seems to be a bug
(reproduces on Maple too)
2023-02-14 16:54:40 -05:00
Jenkins
630d44a8cc chore(i18n): update translations 2023-02-12 16:09:38 -05:00
Jenkins
894e16ddf0 chore(i18n): update translations 2023-02-05 16:09:37 -05:00
Muhammad Abdullah Waheed
263c486330 chore: Automate Browserslist DB Update (#987)
* feat: added cron github action to auto update brwoserlist DB periodically

* refactor: used a shared script to update broswerslist DB, create PR and automerge it
2023-01-31 17:41:10 +05:00
Bilal Qamar
b3d33667d4 Updated frontend-build to v12 (#962)
* feat: rebase previous frontend-build upgrade

* chore: make welcome message to default to empty
2023-01-30 12:20:07 -05:00
Jenkins
b500546e8d chore(i18n): update translations 2023-01-29 16:09:37 -05:00
leangseu-edx
cb9e0aa52f feat: copy static to dist folder for prod (#1057)
* feat: copy static to dist folder for prod

* chore: update webpack.prod.config.js

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2023-01-25 14:29:00 -05:00
Leangseu Kim
69ff5463b3 fix: update iframe static stylesheet 2023-01-25 10:08:44 -05:00
ihor-romaniuk
3b4561e142 fix: fix alignment in the streak celebration modal 2023-01-24 11:47:43 -05:00
Jenkins
cf3b3a27bc chore(i18n): update translations 2023-01-22 16:09:35 -05:00
Ben Warzeski
3bb7aa06bc Bw/share by unit (#1050)
* fix: disable share feature on component unmount

* fix: share only on set units

* fix: share only on set units

* fix: typo
2023-01-20 11:33:38 -05:00
Ben Warzeski
4cea9e582b Bw/share by unit (#1049)
* fix: disable share feature on component unmount

* fix: share only on set units

* fix: share only on set units
2023-01-20 11:02:37 -05:00
Ben Warzeski
0c74bb5106 fix: disable share feature on component unmount (#1048)
* fix: disable share feature on component unmount

* chore: make sure useEffect run only once

Co-authored-by: Leangseu Kim <lkim@edx.org>
2023-01-20 09:07:18 -05:00
Jansen Kantor
b082f3ed19 feat: remove mmp2p (#1042)
* feat: remove mmp2p experiment folder

* feat: remove AccessExpirationAlertMMP2P

* feat: remove imports references and conditionals for mmp2p
2023-01-19 15:35:01 -05:00
leangseu-edx
5d477cebb2 fix: update social sharing icon and title (#1046)
* fix: update social sharing icon and title

* chore: remove twitter icon
2023-01-18 10:29:09 -05:00
Leangseu Kim
851e49f8fb chore: install react share and implement social share demo 2023-01-17 09:17:40 -05:00
Jansen Kantor
09436dd175 test: each.it indentation (#1043) 2023-01-12 16:00:06 -05:00
Awais Ansari
53c8e01c28 fix: hide discussions sidebar and trigger icon when unit does not exist or enableInContext is false. (#1039)
* fix: removed nonCourseWare topics from topics

* fix: hide discussions sidebar and trigger icon when enableInContext is false
2023-01-09 14:29:42 +05:00
Leangseu Kim
ed2d816bbe fix: upgrade discount space 2023-01-03 12:54:22 -05:00
Awais Ansari
7c067299fb refactor: rename inContext param to inContextSidebar (#1022) 2022-12-30 17:04:33 +05:00
Mehak Nasir
4ee1570bfa fix: sidebar length is fixed (#1023) 2022-12-28 13:34:17 +05:00
Leangseu Kim
91c548847b fix: upgrade paragon to remedy the pipeline fix 2022-12-23 11:15:17 -05:00
renovate[bot]
49440ffb45 chore(deps): update dependency @edx/reactifex to v2.1.1 2022-12-23 11:54:31 +00:00
renovate[bot]
6752447d94 chore(deps): update dependency @edx/browserslist-config to v1.1.1 2022-12-23 05:25:42 +00:00
renovate[bot]
75c6aadb09 fix(deps): update dependency util to v0.12.5 2022-12-23 05:14:37 +00:00
renovate[bot]
9eceb355f6 fix(deps): update dependency reselect to v4.1.7 2022-12-23 05:02:03 +00:00
renovate[bot]
df7786388c fix(deps): update dependency regenerator-runtime to v0.13.11 2022-12-23 04:51:29 +00:00
renovate[bot]
361de31e22 fix(deps): update dependency react-share to v4.4.1 2022-12-23 04:40:17 +00:00
renovate[bot]
9e040ec8f1 fix(deps): update dependency react-redux to v7.2.9 2022-12-22 22:25:46 +00:00
renovate[bot]
8db8aeed71 fix(deps): update dependency classnames to v2.3.2 2022-12-22 22:15:18 +00:00
renovate[bot]
04471e550b fix(deps): update dependency @popperjs/core to v2.11.6 2022-12-22 22:04:21 +00:00
renovate[bot]
925ee97a76 fix(deps): update dependency @edx/frontend-lib-special-exams to v2.1.2 2022-12-22 21:54:00 +00:00
renovate[bot]
65086af173 chore(deps): update dependency @testing-library/jest-dom to v5.16.5 2022-12-22 21:42:53 +00:00
Adam Stankiewicz
33923d9a69 chore: upgrade @edx/frontend-build from v9 -> v12 (#1017)
* chore: ignore eslint issues during frontend-build v9 -> v12 upgrade

* chore: add comment to .eslintrc.js file

* chore: update frontend-build

* chore: update test and remove a few unit tests

Co-authored-by: Leangseu Kim <lkim@edx.org>
2022-12-22 13:44:02 -05:00
leangseu-edx
080d31e934 Revert "fix: update transifex flag for tx cli 1.4.0"
This reverts commit f8a1147571.
2022-12-20 16:06:36 -05:00
Leangseu Kim
f3c80ed39b fix: text in goal modal 2022-12-20 15:03:08 -05:00
Leangseu Kim
1ca4eda08a chore: fix fo format relative time 2022-12-19 11:10:34 -05:00
Jenkins
6193c2d1b3 chore(i18n): update translations 2022-12-18 16:04:31 -05:00
leangseu-edx
f8a1147571 fix: update transifex flag for tx cli 1.4.0 2022-12-14 11:40:24 -05:00
Ghassan Maslamani
edba1600dc fix: fix tabs urls on progress tab
This fix does fixes the url links by getting them from the state
 simliar to how tabs navigation gets them.

 This would allow it work for PUBLIC_PATH is not '/', i.e. in
 tutor.

  This was reported in openedx/build-test-release-wg/issues/222
2022-12-07 17:55:15 +00:00
Jenkins
9a07ad1501 chore(i18n): update translations 2022-12-04 16:04:31 -05:00
Awais Ansari
b343ca7a74 Revert "Revert "fix: remove minHeight from in-context discussion sidebar (#1002)" (#1003)" (#1007)
This reverts commit ba06fd7c98.
2022-12-01 14:17:18 +05:00
Abdullah Waheed
b6d272e99d feat: added new translations in Makefile and updated all the translations 2022-11-30 13:31:18 +00:00
Mubbshar Anwar
0fbb53ae86 fix: update event name (#1004)
update segment event name

VAN-1168
2022-11-23 11:24:20 +05:00
Awais Ansari
ba06fd7c98 Revert "fix: remove minHeight from in-context discussion sidebar (#1002)" (#1003)
This reverts commit 9396fbd9d4.
2022-11-18 18:22:35 +05:00
Awais Ansari
9396fbd9d4 fix: remove minHeight from in-context discussion sidebar (#1002) 2022-11-17 20:59:50 +05:00
Abderraouf Mehdi Bouhali
57d880de70 fix(rtl): mirror new user tour modal background
Mirrors the background image used in the new user tour modal
as it obstructs the readability of the modal title when in RTL
2022-11-07 16:04:17 +00:00
Abderraouf Mehdi Bouhali
bfad5cf684 fix(rtl): use backslash to write fractions (grades) 2022-11-07 16:03:56 +00:00
Jenkins
b0378e1331 chore(i18n): update translations 2022-11-06 16:04:22 -05:00
Andrew Shultz
19d06d60be fix: display onboarding expired after expiration (#997)
Currently expiring soon is displayed 28 days before expiration
and forever afterwards. Adds an actual expired state for after.

Also clarifies the expring soon message which assumed other course,
that was not necessarily true.

Also updates the take action lines when you do not have valid
onboarding to make sure they appear for everything not currently valid
or in process, and updates the submitted process lines to not appear
for expired statuses.
2022-11-03 09:26:42 -04:00
Zachary Hancock
df91fef82e feat: update special exams lib (#992) 2022-10-31 11:49:24 -04:00
Jenkins
7e53ddb685 chore(i18n): update translations 2022-10-30 17:09:24 -04:00
Diana Olarte
be72e36a3a feat: allow runtime configuration (#955)
Allows frontend-app-learning to be configured at
runtime using the LMS's new MFE Configuration API.

Part of openedx/frontend-wg#103
2022-10-27 10:01:43 -04:00
Abderraouf Mehdi Bouhali
fa5cf8f204 fix(rtl): force (%) symbol to follow text direction 2022-10-26 10:31:53 -03:00
Jenkins
759d154e13 chore(i18n): update translations 2022-10-19 05:08:31 -04:00
Abderraouf Mehdi Bouhali
7c4200e9d3 fix(rtl): mirror position of grade rectangles in grade bar (#980)
Translates the rectangles for current and passing
grades when to appear on the right when in RTL.
2022-10-14 12:49:37 -04:00
Kshitij Sobti
e5e73e40ba feat: update discussion sidebar url to allow grouping by subsection (#968)
To enable grouping by subsection in the discussions MFE, this PR updates
the embed URL to the one that supports grouping.

ref: https://github.com/openedx/frontend-app-discussions/pull/281
2022-10-12 17:57:42 +05:00
Muhammad Abdullah Waheed
1892edaade refactor: updated renovate config to auto update minor and patch versions of edx dependencies (#963) 2022-09-28 10:47:31 -04:00
Sarina Canelake
381be9a26b Fix github url strings (org edx -> openedx) (#971)
* fix: fix github url strings (org edx -> openedx)

* fix: update path to .github workflows to read from openedx org
2022-09-14 10:01:48 -04:00
Shafqat Farhan
b3841ef446 feat: [VAN-1081] - Set weekly goal and start course events via query param (#972) 2022-09-13 11:00:09 +05:00
Jenkins
5a897e4ea1 chore(i18n): update translations 2022-09-11 17:07:42 -04:00
Hammad Ahmad Waqas
96ceab8b2f fix: Redirecting to MFE home page instead of access-denied page to save redirection (#970) 2022-09-07 15:39:12 +05:00
Hammad Ahmad Waqas
f9806d0759 feat: added support to check if active enterprise is same as EnterpriseCourseEnrollment object (#967) 2022-09-07 11:39:03 +05:00
edx-semantic-release
a7b584c566 chore(i18n): update translations 2022-08-07 17:03:35 -04:00
Shafqat Farhan
193a184142 feat: [VAN-1000] - Set weekly goal through welcome email via query param (#956) 2022-07-26 09:07:55 +05:00
Hammad Ahmad Waqas
3e76f7ac78 feat: Added support to redirect to DSC if required. (#958) 2022-07-25 13:14:41 +05:00
Maman Khan
36062ff3a6 fix: removed derpreciated codecov package (#953) 2022-06-20 08:10:46 +05:00
ruzniaievdm
6257cb4b58 refactor: Replace PDF course certificate view code (#946)
Co-authored-by: ruzniaievdm <ruzniaievdm@gmail.com>
2022-06-13 09:15:09 -04:00
edx-semantic-release
792d9eb758 chore(i18n): update translations 2022-06-12 17:04:27 -04:00
edx-semantic-release
cd84a15891 chore(i18n): update translations 2022-06-03 13:57:12 -04:00
Diana Catalina Olarte
cafb881a61 fix: show site name instead of edX 2022-05-18 12:23:43 +01:00
edX requirements bot
fd94da0a43 feat: Add package-lock file version check (#941) 2022-05-06 15:53:47 +05:00
edx-semantic-release
1e41547b3e chore(i18n): update translations 2022-04-24 17:04:22 -04:00
Renovate Bot
bf2f123367 fix(deps): update dependency @edx/paragon to v19.18.3 2022-04-21 19:06:04 +00:00
Renovate Bot
0211ecf45e fix(deps): update dependency core-js to v3.22.2 2022-04-21 16:49:25 +00:00
renovate[bot]
36ac129267 fix(deps): update dependency @edx/paragon to v19.18.0 (#926)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-04-21 12:32:06 -04:00
renovate[bot]
20d4c35d83 fix(deps): update dependency core-js to v3.22.0 (#927)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-04-21 12:30:10 -04:00
Michael Terry
bbff8e719e fix: remove support for the legacy courseware pages
Access to learners for these pages has been removed, so we don't
need to keep any support for it around. Simplifies some code paths.
2022-04-21 08:50:53 -04:00
edx-semantic-release
5461c08169 chore(i18n): update translations 2022-04-17 17:09:11 -04:00
Michael Terry
ee88a12d8f fix: assume that dates & outline legacy tabs don't exist
They've both been removed from the LMS now. It would be harmless
to keep support for them in place, but it's pointless because
any redirects to the LMS will just come right back to us.

AA-799
2022-04-15 12:26:47 -04:00
Muhammad Adeel Tajamul
9b316bd859 feat: added live tab (#923)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-04-14 10:04:26 +05:00
Renovate Bot
7e7eb83596 fix(deps): update dependency @edx/paragon to v19.15.1 2022-04-13 22:08:11 +00:00
Thomas Tracy
aaa367780d fix: [MICROBA-1769] Cert status before course end (#918)
* fix: [MICROBA-1769] Cert status before course end

Right now, learners who are nonpassing are able to view information
about thier certificates early at the course end screen and progress
pages. This is because we show messaging around the nonpassing state in
some cases before a course ends and certificates are available. This can
also lead to cases where grades are not finalized and students who may
be passing see a scary nonpassing message instead.

This change makes it so during the course exit, a student who finishes a
course before the course is over will see the celebration screen
regardless of passing status. Once the course is over (or if
certificates are available immediately), and they are
still not passing, they will see the nonpassing messaging. The same
change was made for the certificate status alert in the progress tab.
2022-04-13 10:14:00 -04:00
Kshitij Sobti
6d42ee9c6f feat: add discussions tab [BD-38] [TNL-9743] (#879)
* feat: add discussions tab

Adds code to load the discussions MFE in an iframe in the tab so the user isn't redirected to the LMS.

Adds code for the discussions tab, making it dynamically resize based on contents using a postMessage API.

* feat: update path based on user navigation inside discussions MFE

The discussions MFE will send path change events via the postMessage API so that the learning MFE path can be kept in sync. This will allow reloading a page without having the iframe revert to same path each time.
2022-04-13 19:01:29 +05:00
Renovate Bot
41047f4c88 fix(deps): update dependency @edx/paragon to v19.15.0 2022-04-12 09:55:15 -04:00
Renovate Bot
d83551c809 chore(deps): update dependency @testing-library/react to v12.1.5 2022-04-11 23:37:47 +00:00
Renovate Bot
7c3088901d fix(deps): update dependency @edx/frontend-component-footer to v10.2.4 2022-04-11 17:27:50 +00:00
Renovate Bot
518c9ef6c2 chore(deps): update dependency @edx/reactifex to v2 2022-04-11 10:17:28 -04:00
Michael Terry
ae97efaf2b fix: add back es-check & fsevents for now to fix build
A previous commit (7f37575) dropped es-check, which dropped
fsevents, which caused our build system (which is still using
npm@6) to fail with an error like `Unsupported platform for
fsevents` when trying to install fsevents through a dependency
(e.g. when installing npm aliases).

I am reintroducing all the package-lock changes from that commit
to get back fsevents in a state where that error does not occur.

I think a longer-term fix would be to instead upgrade our build
system to node16 / npm6. But this is an easy fix for now to unblock
the builds.
2022-04-11 09:46:10 -04:00
346 changed files with 28200 additions and 48629 deletions

4
.env
View File

@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
EXAMS_BASE_URL=''
FAVICON_URL=''
IGNORED_ERROR_REGEX=''
INSIGHTS_BASE_URL=''
@@ -28,6 +29,8 @@ LOGO_WHITE_URL=''
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEARCH_CATALOG_URL=''
SEGMENT_KEY=''
@@ -43,3 +46,4 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -28,6 +29,8 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''
@@ -43,3 +46,6 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -28,6 +29,8 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''
@@ -42,3 +45,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'

View File

@@ -1,11 +1,17 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint', {
overrides: [{
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
rules: {
'import/named': 'off',
'import/no-extraneous-dependencies': 'off',
},
}],
const config = createConfig('eslint', {
rules: {
// TODO: all these rules should be renabled/addressed. temporarily turned off to unblock a release.
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'import/no-extraneous-dependencies': 'off',
'no-restricted-exports': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/no-unknown-property': 'off',
'func-names': 'off',
},
});
module.exports = config;

View File

@@ -16,4 +16,4 @@ jobs:
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 }}
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

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

View File

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

View File

@@ -9,14 +9,13 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
node-version: ${{ env.NODE_VER }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v3

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
.DS_Store
.eslintcache
.idea
*.swp
*.swo
node_modules
npm-debug.log
coverage

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18

View File

@@ -1,6 +1,7 @@
export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
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
@@ -42,9 +43,24 @@ push_translations:
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,10 +1,12 @@
#####################
frontend-app-learning
######################
|codecov| |license|
frontend-app-learning
=========================
Introduction
------------
********
Purpose
********
This is the Learning MFE (micro-frontend application), which renders all
learner-facing course pages (like the course outline, the progress page,
@@ -15,21 +17,58 @@ Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
Development
-----------
***************
Getting Started
***************
Start Devstack
^^^^^^^^^^^^^^
Prerequisites
=============
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
- Run ``make dev.up.lms``
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
Cloning and Startup
===================
.. code-block::
1. Clone your new repo:
``git clone https://github.com/openedx/frontend-app-learning.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-app-learning && npm ci``
4. Start the dev server:
``npm start``
Local module development
^^^^^^^^^^^^^^^^^^^^^^^^
=========================
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
file (which is git-ignored) that defines where to find your local modules, for instance::
@@ -52,17 +91,17 @@ file (which is git-ignored) that defines where to find your local modules, for i
],
};
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
Deployment
----------
==========
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
Environment Variables
^^^^^^^^^^^^^^^^^^^^^
======================
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
@@ -119,3 +158,59 @@ TWITTER_URL
Example: https://twitter.com/edXOnline
Getting Help
===========
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-learning/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
The Open edX Code of Conduct
============================
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.

View File

@@ -9,4 +9,12 @@ module.exports = createConfig('jest', {
'src/i18n',
'src/.*\\.exp\\..*',
],
// see https://github.com/axios/axios/issues/5026
moduleNameMapper: {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
},
testTimeout: 30000,
testEnvironment: 'jsdom'
});

56858
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Frontend learning application.",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-learning.git"
"url": "git+https://github.com/openedx/frontend-app-learning.git"
},
"browserslist": [
"extends @edx/browserslist-config"
@@ -21,57 +21,64 @@
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme",
"homepage": "https://github.com/openedx/frontend-app-learning#readme",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/edx/frontend-app-learning/issues"
"url": "https://github.com/openedx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.2.2",
"@edx/frontend-component-header": "2.4.6",
"@edx/frontend-lib-special-exams": "1.16.3",
"@edx/frontend-platform": "1.15.6",
"@edx/paragon": "19.14.1",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "12.2.1",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-lib-learning-assistant": "^1.17.0",
"@edx/frontend-lib-special-exams": "2.23.3",
"@edx/frontend-platform": "5.5.2",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@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.18",
"@popperjs/core": "2.11.5",
"@fortawesome/react-fontawesome": "^0.1.4",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.1",
"core-js": "3.21.1",
"js-cookie": "3.0.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"joi": "^17.11.0",
"js-cookie": "3.0.5",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"query-string": "^7.1.3",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.8",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-share": "4.4.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.9",
"reselect": "4.1.5",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.8",
"truncate-html": "1.0.4",
"util": "0.12.4"
"util": "0.12.5"
},
"devDependencies": {
"@edx/browserslist-config": "1.0.2",
"@edx/frontend-build": "9.1.4",
"@edx/reactifex": "1.1.0",
"@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.4",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "^12.9.10",
"@edx/reactifex": "2.2.0",
"@pact-foundation/pact": "^11.0.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"codecov": "3.8.3",
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "27.5.1",
"jest": "29.5.0",
"rosie": "2.1.0"
}
}

View File

@@ -5,5 +5,12 @@
"patch": {
"automerge": true
},
"rebaseStalePrs": true
"rebaseStalePrs": true,
"packageRules": [
{
"matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
]
}

View File

@@ -1,4 +1,3 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
@@ -8,18 +7,8 @@ import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import messages from './messages';
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
function AccessExpirationAlert({ intl, payload }) {
/** [MM-P2P] Experiment */
const [showMMP2P, setShowMMP2P] = useState(!!window.experiment__home_alert_bShowMMP2P);
if (window.experiment__home_alert_showMMP2P === undefined) {
window.experiment__home_alert_showMMP2P = (val) => {
window.experiment__home_alert_bShowMMP2P = !!val;
setShowMMP2P(!!val);
};
}
const AccessExpirationAlert = ({ intl, payload }) => {
const {
accessExpiration,
courseId,
@@ -39,13 +28,6 @@ function AccessExpirationAlert({ intl, payload }) {
upgradeUrl,
} = accessExpiration;
/** [MM-P2P] Experiment */
if (showMMP2P) {
return (
<AccessExpirationAlertMMP2P payload={payload} />
);
}
const logClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org,
@@ -134,7 +116,7 @@ function AccessExpirationAlert({ intl, payload }) {
{deadlineMessage}
</Alert>
);
}
};
AccessExpirationAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,80 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import messages from './messages';
function AccessExpirationAlertMMP2P({ payload }) {
const {
accessExpiration,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (!accessExpiration) {
return null;
}
const {
expirationDate,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
let deadlineMessage = null;
const formatDate = (val, key) => (
<FormattedDate
key={`accessExpiration.${key}`}
day="numeric"
month="short"
year="numeric"
value={val}
{...timezoneFormatArgs}
/>
);
if (upgradeDeadline && upgradeUrl) {
deadlineMessage = (
<>
Upgrade by {formatDate(upgradeDeadline, 'upgradeDesc')} to unlock unlimited access to all course activities, including graded assignments.
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
>
{messages.upgradeNow.defaultMessage}
</Hyperlink>
</>
);
}
return (
<Alert variant="info" icon={Info}>
<span className="font-weight-bold">
Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')}
</span>
<br />
{deadlineMessage}
<br />
You lose all access to the first two weeks of scheduled content
on {formatDate(expirationDate, 'expirationBody')}.
</Alert>
);
}
AccessExpirationAlertMMP2P.propTypes = {
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
masqueradingExpiredCourse: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
upgradeUrl: PropTypes.string,
}).isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default injectIntl(AccessExpirationAlertMMP2P);

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
function AccessExpirationMasqueradeBanner({ payload }) {
const AccessExpirationMasqueradeBanner = ({ payload }) => {
const {
expirationDate,
userTimezone,
@@ -27,7 +27,7 @@ function AccessExpirationMasqueradeBanner({ payload }) {
/>
</PageBanner>
);
}
};
AccessExpirationMasqueradeBanner.propTypes = {
payload: PropTypes.shape({

View File

@@ -7,17 +7,17 @@ const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpira
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
const payload = {
const payload = useMemo(() => ({
accessExpiration,
courseId,
org,
userTimezone,
analyticsPageName,
};
}), [accessExpiration, analyticsPageName, courseId, org, userTimezone]);
useAlert(isVisible, {
code: 'clientAccessExpirationAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic,
});
@@ -34,14 +34,14 @@ export function useAccessExpirationMasqueradeBanner(courseId, tab) {
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
const expirationDate = accessExpiration && accessExpiration.expirationDate;
const payload = {
const payload = useMemo(() => ({
expirationDate,
userTimezone,
};
}), [expirationDate, userTimezone]);
useAlert(isVisible, {
code: 'clientAccessExpirationMasqueradeBanner',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'instructor-toolbar-alerts',
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Hyperlink } from '@edx/paragon';
import { WarningFilled } from '@edx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import genericMessages from './messages';
const ActiveEnterpriseAlert = ({ intl, payload }) => {
const { text, courseId } = payload;
const changeActiveEnterprise = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={
`${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=${encodeURIComponent(
`${global.location.origin}/course/${courseId}/home`,
)}`
}
>
{intl.formatMessage(genericMessages.changeActiveEnterpriseLowercase)}
</Hyperlink>
);
return (
<Alert variant="warning" icon={WarningFilled}>
{text}
<FormattedMessage
id="learning.activeEnterprise.alert"
description="Prompts the user to log-in with the correct enterprise to access the course content."
defaultMessage=" {changeActiveEnterprise}."
values={{
changeActiveEnterprise,
}}
/>
</Alert>
);
};
ActiveEnterpriseAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
text: PropTypes.string,
courseId: PropTypes.string,
}).isRequired,
};
export default injectIntl(ActiveEnterpriseAlert);

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
initializeTestStore, render, screen,
} from '../../setupTest';
import ActiveEnterpriseAlert from './ActiveEnterpriseAlert';
describe('ActiveEnterpriseAlert', () => {
const mockData = {
payload: {
text: 'test message',
courseId: 'test-course-id',
},
};
beforeAll(async () => {
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
});
it('Shows alert message and links', () => {
render(<ActiveEnterpriseAlert {...mockData} />);
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('test message', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute('href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`);
});
});

View File

@@ -0,0 +1,28 @@
import React, { useMemo } from 'react';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert'));
export default function useActiveEnterpriseAlert(courseId) {
const { courseAccess } = useModel('courseHomeMeta', courseId);
/**
* This alert should render if
* 1. course access code is incorrect_active_enterprise
*/
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
const payload = useMemo(() => ({
text: courseAccess && courseAccess.userMessage,
courseId,
}), [courseAccess, courseId]);
useAlert(isVisible, {
code: 'clientActiveEnterpriseAlert',
topic: 'outline',
dismissible: false,
type: ALERT_TYPES.ERROR,
payload,
});
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
}

View File

@@ -0,0 +1,3 @@
import useActiveEnterpriseAlert from './hooks';
export default useActiveEnterpriseAlert;

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
changeActiveEnterpriseLowercase: {
id: 'learning.activeEnterprise.change.alert',
defaultMessage: 'change enterprise now',
description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"',
},
});
export default messages;

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelative,
FormattedRelativeTime,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
@@ -11,9 +11,11 @@ import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
const DAY_SEC = 24 * 60 * 60; // in seconds
const DAY_MS = DAY_SEC * 1000; // in ms
const YEAR_SEC = 365 * DAY_SEC; // in seconds
function CourseStartAlert({ payload }) {
const CourseStartAlert = ({ payload }) => {
const {
courseId,
} = payload;
@@ -25,15 +27,17 @@ function CourseStartAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const delta = new Date(startDate) - new Date();
const timeRemaining = (
<FormattedRelative
<FormattedRelativeTime
key="timeRemaining"
value={startDate}
value={delta / 1000}
numeric="auto"
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
updateIntervalInSeconds={YEAR_SEC}
{...timezoneFormatArgs}
/>
);
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) {
return (
<Alert variant="info" icon={Info}>
@@ -64,7 +68,7 @@ function CourseStartAlert({ payload }) {
<Alert variant="info" icon={Info}>
<strong>
<FormattedMessage
id="learning.outline.alert.end.long"
id="learning.outline.alert.start.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away."
values={{
@@ -84,13 +88,13 @@ function CourseStartAlert({ payload }) {
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.end.calendar"
id="learning.outline.alert.start.calendar"
defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/>
</Alert>
);
}
};
CourseStartAlert.propTypes = {
payload: PropTypes.shape({

View File

@@ -5,7 +5,7 @@ import { PageBanner } from '@edx/paragon';
import { useModel } from '../../generic/model-store';
function CourseStartMasqueradeBanner({ payload }) {
const CourseStartMasqueradeBanner = ({ payload }) => {
const {
courseId,
} = payload;
@@ -33,7 +33,7 @@ function CourseStartMasqueradeBanner({ payload }) {
/>
</PageBanner>
);
}
};
CourseStartMasqueradeBanner.propTypes = {
payload: PropTypes.shape({

View File

@@ -5,7 +5,7 @@ import { useModel } from '../../generic/model-store';
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
function isStartDateInFuture(courseId) {
function IsStartDateInFuture(courseId) {
const {
start,
} = useModel('courseHomeMeta', courseId);
@@ -20,15 +20,15 @@ function useCourseStartAlert(courseId) {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
const isVisible = isEnrolled && isStartDateInFuture(courseId);
const isVisible = isEnrolled && IsStartDateInFuture(courseId);
const payload = {
const payload = useMemo(() => ({
courseId,
};
}), [courseId]);
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'outline-course-alerts',
});
@@ -42,15 +42,15 @@ export function useCourseStartMasqueradeBanner(courseId, tab) {
isMasquerading,
} = useModel('courseHomeMeta', courseId);
const isVisible = isMasquerading && tab === 'progress' && isStartDateInFuture(courseId);
const isVisible = isMasquerading && tab === 'progress' && IsStartDateInFuture(courseId);
const payload = {
const payload = useMemo(() => ({
courseId,
};
}), [courseId]);
useAlert(isVisible, {
code: 'clientCourseStartMasqueradeBanner',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'instructor-toolbar-alerts',
});

View File

@@ -11,7 +11,7 @@ import { useModel } from '../../generic/model-store';
import messages from './messages';
import useEnrollClickHandler from './clickHook';
function EnrollmentAlert({ intl, payload }) {
const EnrollmentAlert = ({ intl, payload }) => {
const {
canEnroll,
courseId,
@@ -55,7 +55,7 @@ function EnrollmentAlert({ intl, payload }) {
</div>
</Alert>
);
}
};
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -27,7 +27,7 @@ function useEnrollClickHandler(courseId, orgId, successText) {
});
global.location.reload();
});
}, [courseId]);
}, [addFlash, courseId, orgId, successText]);
return { enrollClickHandler, loading };
}

View File

@@ -22,16 +22,16 @@ export function useEnrollmentAlert(courseId) {
* 3. the course is private.
*/
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
const payload = {
const payload = useMemo(() => ({
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId,
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
isStaff: course && course.isStaff,
};
}), [course, courseId, outline]);
useAlert(isVisible, {
code: 'clientEnrollmentAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'outline',
});

View File

@@ -13,9 +13,9 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';
function AccountActivationAlert({
const AccountActivationAlert = ({
intl,
}) {
}) => {
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
@@ -123,7 +123,7 @@ function AccountActivationAlert({
{children()}
</AlertModal>
);
}
};
AccountActivationAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -7,7 +7,7 @@ import { WarningFilled } from '@edx/paragon/icons';
import genericMessages from '../../generic/messages';
function LogistrationAlert({ intl }) {
const LogistrationAlert = ({ intl }) => {
const signIn = (
<Hyperlink
style={{ textDecoration: 'underline' }}
@@ -41,7 +41,7 @@ function LogistrationAlert({ intl }) {
/>
</Alert>
);
}
};
LogistrationAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -35,7 +35,8 @@ function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
if (entranceExamPassed) {
entranceExamText = intl.formatMessage(
messages.entranceExamTextPassed, { entranceExamCurrentScore: entranceExamCurrentScore * 100 },
messages.entranceExamTextPassed,
{ entranceExamCurrentScore: entranceExamCurrentScore * 100 },
);
} else {
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {

33
src/constants.js Normal file
View File

@@ -0,0 +1,33 @@
export const DECODE_ROUTES = {
ACCESS_DENIED: '/course/:courseId/access-denied',
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
],
COURSE_END: '/course/:courseId/course-end',
COURSEWARE: [
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
};
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
};

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Tabs, Tab } from '@edx/paragon';
import { useParams } from 'react-router';
import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages';
import { useModel } from '../../generic/model-store';
const noFilterKey = 'none';
const noFilterLabel = 'All content';
export const filteredResultsBySelection = ({ key = noFilterKey, results = [] }) => (
key === noFilterKey ? results : results.filter(({ type }) => type === key)
);
export const CoursewareSearchResultsFilter = ({ intl }) => {
const { courseId } = useParams();
const lastSearch = useModel('contentSearchResults', courseId);
if (!lastSearch || !lastSearch?.results?.length) { return null; }
const { total, results } = lastSearch;
const filters = [
{
key: noFilterKey,
label: noFilterLabel,
count: total,
},
...lastSearch.filters,
];
const getFilterTitle = (key, fallback) => {
const msg = messages[`filter:${key}`];
if (!msg) { return fallback; }
return intl.formatMessage(msg);
};
return (
<Tabs
id="courseware-search-results-tabs"
data-testid="courseware-search-results-tabs"
variant="tabs"
defaultActiveKey={noFilterKey}
>
{filters.map(({ key, label }) => (
<Tab key={key} eventKey={key} title={getFilterTitle(key, label)}>
<CoursewareSearchResults
results={filteredResultsBySelection({ key, results })}
/>
</Tab>
))}
</Tabs>
);
};
CoursewareSearchResultsFilter.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchResultsFilter);

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { Route, Routes } from 'react-router-dom';
import { history } from '@edx/frontend-platform';
import {
initializeMockApp,
render,
screen,
waitFor,
} from '../../setupTest';
import { CoursewareSearchResultsFilter, filteredResultsBySelection } from './CoursewareResultsFilter';
import initializeStore from '../../store';
import { useModel } from '../../generic/model-store';
jest.mock('../../generic/model-store', () => ({
useModel: jest.fn(),
}));
const mockResults = [
{
id: 'video-1', type: 'video', title: 'video_title', score: 3, contentHits: 1, url: '/video-1', location: ['path1', 'path2'],
},
{
id: 'video-2', type: 'video', title: 'video_title2', score: 2, contentHits: 1, url: '/video-2', location: ['path1', 'path2'],
},
{
id: 'document-1', type: 'document', title: 'document_title', score: 3, contentHits: 1, url: '/document-1', location: ['path1', 'path2'],
},
{
id: 'text-1', type: 'text', title: 'text_title1', score: 3, contentHits: 1, url: '/text-1', location: ['path1', 'path2'],
},
{
id: 'text-2', type: 'text', title: 'text_title2', score: 2, contentHits: 1, url: '/text-2', location: ['path1', 'path2'],
},
{
id: 'text-3', type: 'text', title: 'text_title3', score: 1, contentHits: 1, url: '/text-3', location: ['path1', 'path2'],
},
];
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
const pathname = `/course/${decodedCourseId}/${decodedSequenceId}/${decodedUnitId}`;
const intl = {
formatMessage: (message) => message?.defaultMessage || '',
};
function renderComponent(props = {}) {
const store = initializeStore();
history.push(pathname);
const { container } = render(
<AppProvider store={store}>
<Routes>
<Route path="/course/:courseId/:sequenceId/:unitId" element={<CoursewareSearchResultsFilter intl={intl} {...props} />} />
</Routes>
</AppProvider>,
);
return container;
}
describe('CoursewareSearchResultsFilter', () => {
beforeAll(initializeMockApp);
describe('filteredResultsBySelection', () => {
it('returns a no values array when no results are provided', () => {
const results = filteredResultsBySelection({ results: [] });
expect(results.length).toEqual(0);
});
it('returns all values when no key value is provided', () => {
const results = filteredResultsBySelection({ results: mockResults });
expect(results.length).toEqual(mockResults.length);
});
it('returns only "video"-typed elements when the key value "video" is given', () => {
const results = filteredResultsBySelection({ key: 'video', results: mockResults });
expect(results.length).toEqual(2);
});
it('returns only "course_outline"-typed elements when the key value "document" is given', () => {
const results = filteredResultsBySelection({ key: 'document', results: mockResults });
expect(results.length).toEqual(1);
});
it('returns only "text"-typed elements when the key value "text" is given', () => {
const results = filteredResultsBySelection({ key: 'text', results: mockResults });
expect(results.length).toEqual(3);
});
});
describe('</CoursewareSearchResultsFilter />', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render', async () => {
useModel.mockReturnValue({
total: 6,
results: mockResults,
filters: [],
});
await renderComponent();
await waitFor(() => {
expect(screen.queryByTestId('courseware-search-results-tabs')).toBeInTheDocument();
expect(screen.queryByText(/All content/)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import { useParams } from 'react-router';
import { useDispatch } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Alert, Button, Icon, Spinner,
} from '@edx/paragon';
import {
Close,
} from '@edx/paragon/icons';
import { setShowSearch } from '../data/slice';
import { useElementBoundingBox, useLockScroll } from './hooks';
import messages from './messages';
import CoursewareSearchForm from './CoursewareSearchForm';
import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter';
import { updateModel, useModel } from '../../generic/model-store';
import { searchCourseContent } from '../data/thunks';
const CoursewareSearch = ({ intl, ...sectionProps }) => {
const { courseId } = useParams();
const dispatch = useDispatch();
const { org } = useModel('courseHomeMeta', courseId);
const {
loading,
searchKeyword: lastSearchKeyword,
errors,
total,
} = useModel('contentSearchResults', courseId);
const [searchKeyword, setSearchKeyword] = useState(lastSearchKeyword);
useLockScroll();
const info = useElementBoundingBox('courseTabsNavigation');
const top = info ? `${Math.floor(info.top)}px` : 0;
const clearSearch = () => {
dispatch(updateModel({
modelType: 'contentSearchResults',
model: {
id: courseId,
searchKeyword: '',
results: [],
errors: undefined,
loading: false,
},
}));
};
const handleSubmit = () => {
if (!searchKeyword) {
clearSearch();
return;
}
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
...eventProperties,
event_type: 'searchKeyword',
keyword: searchKeyword,
});
dispatch(searchCourseContent(courseId, searchKeyword));
};
const handleOnChange = (value) => {
if (value === searchKeyword) { return; }
setSearchKeyword(value);
if (!value) {
clearSearch();
}
};
let status = 'idle';
if (loading) {
status = 'loading';
} else if (errors) {
status = 'error';
} else if (lastSearchKeyword) {
status = 'results';
}
return (
<section className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-section" {...sectionProps}>
<div className="courseware-search__close">
<Button
variant="tertiary"
className="p-1"
aria-label={intl.formatMessage(messages.searchCloseAction)}
onClick={() => dispatch(setShowSearch(false))}
data-testid="courseware-search-close-button"
><Icon src={Close} />
</Button>
</div>
<div className="courseware-search__outer-content">
<div className="courseware-search__content">
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
<CoursewareSearchForm
searchTerm={searchKeyword}
onSubmit={handleSubmit}
onChange={handleOnChange}
placeholder={intl.formatMessage(messages.searchBarPlaceholderText)}
/>
{status === 'loading' ? (
<div className="courseware-search__spinner">
<Spinner animation="border" variant="light" screenReaderText={intl.formatMessage(messages.loading)} />
</div>
) : null}
{status === 'error' && (
<Alert className="mt-4" variant="danger">
{intl.formatMessage(messages.searchResultsError)}
</Alert>
)}
{status === 'results' ? (
<>
<div className="courseware-search__results-summary">{total > 0
? (
intl.formatMessage(
total === 1
? messages.searchResultsSingular
: messages.searchResultsPlural,
{ total, keyword: lastSearchKeyword },
)
) : intl.formatMessage(messages.searchResultsNone)}
</div>
<CoursewareSearchResultsFilterContainer />
</>
) : null}
</div>
</div>
</section>
);
};
CoursewareSearch.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearch);

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { Route, Routes } from 'react-router-dom';
import {
initializeMockApp,
render,
screen,
} from '../../setupTest';
import { CoursewareSearch } from './index';
import { useElementBoundingBox, useLockScroll } from './hooks';
import initializeStore from '../../store';
import { useModel } from '../../generic/model-store';
jest.mock('./hooks');
jest.mock('../../generic/model-store', () => ({
useModel: jest.fn(),
}));
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
const pathname = `/course/${decodedCourseId}/${decodedSequenceId}/${decodedUnitId}`;
const tabsTopPosition = 128;
const defaultProps = {
org: 'edX',
loading: false,
searchKeyword: '',
errors: undefined,
total: 0,
};
const intl = {
formatMessage: (message) => message?.defaultMessage || '',
};
function renderComponent(props = {}) {
const store = initializeStore();
history.push(pathname);
const { container } = render(
<AppProvider store={store}>
<Routes>
<Route path="/course/:courseId/:sequenceId/:unitId" element={<CoursewareSearch intl={intl} {...props} />} />
</Routes>
</AppProvider>,
);
return container;
}
const mockModels = ((props = defaultProps) => {
useModel.mockReturnValue(props);
});
describe('CoursewareSearch', () => {
beforeAll(initializeMockApp);
beforeEach(() => {
jest.clearAllMocks();
});
describe('when rendering normally', () => {
beforeAll(() => {
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
});
it('Should use useElementBoundingBox() and useLockScroll() hooks', () => {
mockModels();
renderComponent();
expect(useElementBoundingBox).toBeCalledTimes(1);
expect(useLockScroll).toBeCalledTimes(1);
});
it('Should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
mockModels();
renderComponent();
const section = screen.getByTestId('courseware-search-section');
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
});
});
describe('when CourseTabsNavigation is not present', () => {
it('Should use "--modal-top-position: 0" if nce element is not present', () => {
useElementBoundingBox.mockImplementation(() => undefined);
mockModels();
renderComponent();
const section = screen.getByTestId('courseware-search-section');
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
});
});
describe('when passing extra props', () => {
it('Should pass on extra props to section element', () => {
mockModels();
renderComponent({ foo: 'bar' });
const section = screen.getByTestId('courseware-search-section');
expect(section).toHaveAttribute('foo', 'bar');
});
});
});

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CoursewareSearchEmpty = ({ intl }) => (
<div className="courseware-search-results">
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
</div>
);
CoursewareSearchEmpty.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchEmpty);

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { SearchField } from '@edx/paragon';
import PropTypes from 'prop-types';
const CoursewareSearchForm = ({
searchTerm,
onSubmit,
onChange,
placeholder,
}) => (
<SearchField.Advanced
value={searchTerm}
onSubmit={onSubmit}
onChange={onChange}
submitButtonLocation="external"
className="courseware-search-form"
>
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
<SearchField.Label />
<SearchField.Input placeholder={placeholder} autoFocus />
<SearchField.ClearButton />
</div>
<SearchField.SubmitButton submitButtonLocation="external" />
</SearchField.Advanced>
);
CoursewareSearchForm.propTypes = {
searchTerm: PropTypes.string,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
placeholder: PropTypes.string,
};
CoursewareSearchForm.defaultProps = {
searchTerm: undefined,
onSubmit: undefined,
onChange: undefined,
placeholder: undefined,
};
export default CoursewareSearchForm;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import {
act,
initializeMockApp,
render,
screen,
waitFor,
fireEvent,
} from '../../setupTest';
import CoursewareSearchForm from './CoursewareSearchForm';
function renderComponent(placeholder, onSubmit, onChange) {
const { container } = render(<CoursewareSearchForm
placeholder={placeholder}
onSubmit={onSubmit}
onChange={onChange}
/>);
return container;
}
describe('CoursewareSearchToggle', () => {
const placeholderText = 'Search for courseware';
let onSubmitHandlerMock;
let onChangeHandlerMock;
beforeAll(async () => {
onChangeHandlerMock = jest.fn();
onSubmitHandlerMock = jest.fn();
initializeMockApp();
});
it('should render', async () => {
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
await waitFor(() => {
expect(screen.queryByTestId('courseware-search-form')).toBeInTheDocument();
});
});
it('should call onChange handler when input changes', async () => {
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
await waitFor(() => {
const element = screen.queryByPlaceholderText(placeholderText);
fireEvent.change(element, { target: { value: 'test' } });
expect(onChangeHandlerMock).toHaveBeenCalledTimes(1);
});
});
it('should call onSubmit handler when submit is clicked', async () => {
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
await waitFor(() => {
const element = screen.queryAllByText('Search')[0];
fireEvent.click(element);
expect(onSubmitHandlerMock).toHaveBeenCalledTimes(1);
});
});
afterEach(() => {
jest.clearAllMocks();
});
});

View File

@@ -0,0 +1,88 @@
import React from 'react';
import {
Folder, TextFields, VideoCamera, Article,
} from '@edx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { Icon } from '@edx/paragon';
import PropTypes from 'prop-types';
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
const iconTypeMapping = {
document: Folder,
text: TextFields,
video: VideoCamera,
};
const defaultIcon = Article;
const CoursewareSearchResults = ({ results }) => {
if (!results?.length) {
return <CoursewareSearchEmpty />;
}
const baseUrl = `${getConfig().LMS_BASE_URL}`;
return (
<div className="courseware-search-results" data-testid="search-results">
{results.map(({
id,
title,
type,
location,
url,
contentHits,
}) => {
const key = type.toLowerCase();
const icon = iconTypeMapping[key] || defaultIcon;
const isExternal = !url.startsWith('/');
const linkProps = isExternal ? {
href: url,
target: '_blank',
rel: 'nofollow',
} : { href: `${baseUrl}${url}` };
return (
<a key={id} className="courseware-search-results__item" {...linkProps}>
<div className="courseware-search-results__icon"><Icon src={icon} /></div>
<div className="courseware-search-results__info">
<div className="courseware-search-results__title">
<span>{title}</span>
{contentHits ? (<em>{contentHits}</em>) : null }
</div>
{location?.length ? (
<ul className="courseware-search-results__breadcrumbs">
{
// This ignore is necessary because the breadcrumb texts might have duplicates.
// The breadcrumbs are not expected to change.
// eslint-disable-next-line react/no-array-index-key
location.map((breadcrumb, i) => (<li key={`${i}:${breadcrumb}`}><div>{breadcrumb}</div></li>))
}
</ul>
) : null}
</div>
</a>
);
})}
</div>
);
};
CoursewareSearchResults.propTypes = {
results: PropTypes.arrayOf(PropTypes.objectOf({
id: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
location: PropTypes.arrayOf(PropTypes.string),
url: PropTypes.string,
contentHits: PropTypes.number,
score: PropTypes.number,
})),
};
CoursewareSearchResults.defaultProps = {
results: [],
};
export default CoursewareSearchResults;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import {
initializeMockApp,
render,
screen,
} from '../../setupTest';
import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages';
// import mockedData from './test-data/mockedResults'; // TODO: Update this test.
jest.mock('react-redux');
function renderComponent({ results }) {
const { container } = render(<CoursewareSearchResults results={results} />);
return container;
}
describe('CoursewareSearchResults', () => {
beforeAll(async () => {
initializeMockApp();
});
describe('when an empty array is provided', () => {
beforeEach(() => { renderComponent({ results: [] }); });
it('should render a "no results found" message.', () => {
expect(screen.getByTestId('no-results').textContent).toBe(messages.searchResultsNone.defaultMessage);
});
});
/* describe('when list of results is provided', () => {
beforeEach(() => { renderComponent({ results: mockedData }); });
it('should match the snapshot', () => {
expect(screen.getByTestId('search-results')).toMatchSnapshot();
});
}); */
});

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { useDispatch } from 'react-redux';
import { setShowSearch } from '../data/slice';
import messages from './messages';
import { useCoursewareSearchFeatureFlag } from './hooks';
const CoursewareSearchToggle = ({
intl,
}) => {
const dispatch = useDispatch();
const enabled = useCoursewareSearchFeatureFlag();
if (!enabled) { return null; }
return (
<div className="courseware-searc-toggle">
<Button
variant="tertiary"
size="sm"
className="p-1 mt-2 mr-2 rounded-lg"
aria-label={intl.formatMessage(messages.searchOpenAction)}
onClick={() => dispatch(setShowSearch(true))}
data-testid="courseware-search-open-button"
>
<Icon src={Search} />
</Button>
</div>
);
};
CoursewareSearchToggle.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchToggle);

View File

@@ -0,0 +1,67 @@
import React from 'react';
import {
act,
fireEvent,
initializeMockApp,
render,
screen,
waitFor,
} from '../../setupTest';
import { fetchCoursewareSearchSettings } from '../data/thunks';
import { setShowSearch } from '../data/slice';
import { CoursewareSearchToggle } from './index';
const mockDispatch = jest.fn();
jest.mock('../data/thunks');
jest.mock('../data/slice');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
function renderComponent() {
const { container } = render(<CoursewareSearchToggle />);
return container;
}
describe('CoursewareSearchToggle', () => {
beforeAll(async () => {
initializeMockApp();
});
afterEach(() => {
jest.clearAllMocks();
});
it('Should not render when the waffle flag is disabled', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
await act(async () => renderComponent());
await waitFor(() => {
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('courseware-search-open-button')).not.toBeInTheDocument();
});
});
it('Should render when the waffle flag is enabled', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
await act(async () => renderComponent());
await waitFor(() => {
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('courseware-search-open-button')).toBeInTheDocument();
});
});
it('Should dispatch setShowSearch(true) when clicking the search button', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
await act(async () => renderComponent());
const button = await screen.findByTestId('courseware-search-open-button');
fireEvent.click(button);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setShowSearch).toHaveBeenCalledTimes(1);
expect(setShowSearch).toHaveBeenCalledWith(true);
});
});

View File

@@ -0,0 +1,292 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
Object {
"filters": Array [
Object {
"count": 7,
"key": "capa",
"label": "CAPA",
},
Object {
"count": 2,
"key": "sequence",
"label": "Sequence",
},
Object {
"count": 9,
"key": "text",
"label": "Text",
},
Object {
"count": 2,
"key": "video",
"label": "Video",
},
],
"maxScore": 3.4545178,
"ms": 5,
"results": Array [
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
"location": Array [
"Introduction",
"Demo Course Overview",
],
"score": 3.4545178,
"title": "Demo Course Overview",
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
"location": Array [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
],
"score": 3.4545178,
"title": "Passing a Course",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
"location": Array [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
],
"score": 3.4545178,
"title": "Passing a Course",
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
"location": Array [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Text input",
],
"score": 1.5874016,
"title": "Text Input",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
"location": Array [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Pointing on a Picture",
],
"score": 1.5499392,
"title": "Pointing on a Picture",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
"location": Array [
"About Exams and Certificates",
"edX Exams",
"Getting Answers",
],
"score": 1.5003732,
"title": "Getting Answers",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
"location": Array [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
],
"score": 1.4792063,
"title": "Welcome!",
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
"location": Array [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Multiple Choice Questions",
],
"score": 1.4341705,
"title": "Multiple Choice Questions",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
"location": Array [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Numerical Input",
],
"score": 1.2987298,
"title": "Numerical Input",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
"location": Array [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Video Presentation Styles",
],
"score": 1.1870136,
"title": "Connecting a Circuit and a Circuit Diagram",
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
"location": Array [
"Example Week 2: Get Interactive",
"Homework - Labs and Demos",
"Code Grader",
],
"score": 1.0107487,
"title": "CAPA",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
"location": Array [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Interactive Questions",
],
"score": 0.96387196,
"title": "Interactive Questions",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
"location": Array [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
],
"score": 0.8844358,
"title": "Blank HTML Page",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
"location": Array [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Discussion Forums",
],
"score": 0.8803684,
"title": "Discussion Forums",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
"location": Array [
"About Exams and Certificates",
"edX Exams",
"Overall Grade Performance",
],
"score": 0.87981963,
"title": "Overall Grade",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
"location": Array [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Homework - Find Your Study Buddy",
],
"score": 0.84284115,
"title": "Blank HTML Page",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
"location": Array [
"Example Week 3: Be Social",
"Homework - Find Your Study Buddy",
"Homework - Find Your Study Buddy",
],
"score": 0.84284115,
"title": "Find Your Study Buddy",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
"location": Array [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Be Social",
],
"score": 0.84210813,
"title": "Be Social",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
"location": Array [
"About Exams and Certificates",
"edX Exams",
"EdX Exams",
],
"score": 0.8306555,
"title": "EdX Exams",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
},
Object {
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
"location": Array [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"When Are Your Exams? ",
],
"score": 0.82610154,
"title": "When Are Your Exams? ",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
},
],
"total": 29,
}
`;

View File

@@ -0,0 +1,152 @@
.courseware-search {
background: white;
position: fixed;
top: var(--modal-top-position, 0);
left: 0;
right: 0;
bottom: 0;
border-top: 1px solid $light-300;
z-index: 200;
&__close {
position: absolute !important; // For some reason it gets overridden
top: 0.5rem;
right: 1rem;
font-size: 1.5rem;
line-height: 1;
}
&__outer-content {
overflow-y: auto;
height: 100%;
}
&__content {
padding-top: 2rem;
padding-left: 1rem;
padding-right: 1rem;
max-width: 42rem;
margin: auto;
h2 {
margin-bottom: 2rem;
}
}
&__results-summary {
font-size: .9rem;
color: $gray-400;
padding: 1rem 0 .5rem;
}
&__spinner {
display: grid;
place-items: center;
min-height: 20vh;
}
}
.courseware-search-results {
margin-top: 1.5rem;
&__empty {
color: $gray-500;
}
&__item {
display: block;
padding: .75rem 1rem;
font-weight: 500;
display: flex;
gap: 0.625rem;
&:hover {
text-decoration: none;
background: $light-300;
}
&:not(:first-child) {
border-top: 1px solid $light-300;
}
}
&__icon {
padding: 0.375rem 0 0 0.375rem;
color: $gray-300;
}
&__info {
flex: 1;
overflow: hidden;
}
&__title {
display: flex;
align-items: center;
line-height: 2.5;
font-size: 0.875rem;
color: $black;
> span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
em {
padding: 0.125rem 0.375rem;
font-variant-numeric: lining-nums tabular-nums;
min-width: 1.25rem;
line-height: 1rem;
background: $light-300;
border-radius: 99rem;
font-style: normal;
margin-left: 0.375rem;
font-size: 0.6875rem;
text-align: center;
}
}
&__breadcrumbs {
display: flex;
gap: 1.25rem;
color: $gray-400;
overflow: hidden;
list-style: none;
padding: 0;
margin: 0;
> li {
position: relative;
flex-shrink: 1;
min-width: 0;
&:not(:first-child)::before {
content: '';
position: absolute;
top: 50%;
transform: translate(-50%, -55%);
left: -0.625rem;
}
}
div {
font-size: 0.75rem;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@media (min-width: map-get($grid-breakpoints, 'md')) {
.courseware-search__content {
padding-top: 8rem;
}
}
body._search-no-scroll {
overflow-y: hidden;
}

View File

@@ -0,0 +1,71 @@
import { useState, useEffect, useLayoutEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { debounce } from 'lodash';
import { fetchCoursewareSearchSettings } from '../data/thunks';
const DEBOUNCE_WAIT = 100; // ms
export function useCoursewareSearchFeatureFlag() {
const { courseId } = useParams();
const [enabled, setEnabled] = useState(false);
useEffect(() => {
fetchCoursewareSearchSettings(courseId).then(response => setEnabled(response.enabled));
}, [courseId]);
return enabled;
}
export function useCoursewareSearchState() {
const enabled = useCoursewareSearchFeatureFlag();
const show = useSelector(state => state.courseHome.showSearch);
return { show: enabled && show };
}
export function useElementBoundingBox(elementId) {
const [info, setInfo] = useState(undefined);
const element = document.getElementById(elementId);
if (!element) {
console.warn(`useElementBoundingBox(): Unable to find element with id='${elementId}' in the document.`); // eslint-disable-line no-console
return undefined;
}
useLayoutEffect(() => {
// Handler to call on window resize and scroll
function recalculate() {
const bounds = element.getBoundingClientRect();
setInfo(bounds);
}
const debouncedRecalculate = debounce(recalculate, DEBOUNCE_WAIT, { leading: true });
// Add event listener
global.addEventListener('resize', debouncedRecalculate);
global.addEventListener('scroll', debouncedRecalculate);
// Call handler right away so state gets updated with initial window size
debouncedRecalculate();
// Remove event listener on cleanup
return () => {
global.removeEventListener('resize', debouncedRecalculate);
global.removeEventListener('scroll', debouncedRecalculate);
};
}, []);
return info;
}
export function useLockScroll() {
useLayoutEffect(() => {
window.scrollTo(0, 0);
document.body.classList.add('_search-no-scroll');
return () => {
document.body.classList.remove('_search-no-scroll');
};
}, []);
}

View File

@@ -0,0 +1,187 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { fetchCoursewareSearchSettings } from '../data/thunks';
import {
useCoursewareSearchFeatureFlag, useCoursewareSearchState, useElementBoundingBox, useLockScroll,
} from './hooks';
jest.mock('react-redux');
jest.mock('react-router-dom');
jest.mock('../data/thunks');
describe('CoursewareSearch Hooks', () => {
const courses = {
123: { enabled: true },
456: { enabled: false },
};
beforeEach(() => {
fetchCoursewareSearchSettings.mockImplementation((courseId) => Promise.resolve(courses[courseId]));
});
afterEach(() => {
jest.resetAllMocks();
});
describe('useCoursewareSearchFeatureFlag', () => {
const renderTestHook = async (enabled = true) => {
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
let hook;
await act(async () => { (hook = renderHook(() => useCoursewareSearchFeatureFlag())); });
return hook;
};
it('should return true if feature is enabled', async () => {
const hook = await renderTestHook();
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
expect(hook.result.current).toBe(true);
});
it('should return false if feature is disabled', async () => {
const hook = await renderTestHook(false);
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
expect(hook.result.current).toBe(false);
});
});
describe('useCoursewareSearchState', () => {
const renderTestHook = async ({ enabled, showSearch }) => {
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
const mockedStoreState = { courseHome: { showSearch } };
useSelector.mockImplementation(selector => selector(mockedStoreState));
let hook;
await act(async () => { (hook = renderHook(() => useCoursewareSearchState())); });
return hook;
};
it('should return show: true if feature is enabled and showSearch is true', async () => {
const hook = await renderTestHook({ enabled: true, showSearch: true });
expect(hook.result.current).toEqual({ show: true });
});
it('should return show: false in any other case', async () => {
let hook;
hook = await renderTestHook({ enabled: true, showSearch: false });
expect(hook.result.current).toEqual({ show: false });
hook = await renderTestHook({ enabled: false, showSearch: true });
expect(hook.result.current).toEqual({ show: false });
hook = await renderTestHook({ enabled: false, showSearch: false });
expect(hook.result.current).toEqual({ show: false });
});
});
describe('useElementBoundingBox', () => {
let getBoundingClientRectSpy;
const renderTestHook = async ({ elementId, mockedInfo }) => {
getBoundingClientRectSpy = jest.spyOn(document, 'getElementById').mockImplementation(() => (
mockedInfo
? { getBoundingClientRect: () => ({ ...mockedInfo }) }
: undefined
));
let hook;
await act(async () => {
hook = renderHook(() => useElementBoundingBox(elementId));
});
return hook;
};
let addEventListenerSpy;
let removeEventListenerSpy;
beforeEach(() => {
addEventListenerSpy = jest.spyOn(global, 'addEventListener');
removeEventListenerSpy = jest.spyOn(global, 'removeEventListener');
});
describe('when element is present', () => {
const mockedInfo = { top: 128 };
it('should bind resize and scroll events on mount', async () => {
await renderTestHook({ elementId: 'test', mockedInfo });
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
});
it('should unbindbind resize and scroll events when unmounted', async () => {
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
hook.unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
});
it('should return the element bounding box', async () => {
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
hook.waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
expect(hook.result.current).toEqual(mockedInfo);
});
it('should call getBoundingClientRect on window resize', async () => {
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
act(() => {
// Trigger the window resize event.
global.innerWidth = 500;
global.dispatchEvent(new Event('resize'));
});
expect(hook.result.current).toEqual(mockedInfo);
});
});
describe('when element is NOT present', () => {
let consoleWarnSpy;
beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
});
it('should log a warning and return undefined', async () => {
await renderTestHook({ elementId: 'happiness' });
expect(consoleWarnSpy).toHaveBeenCalledWith("useElementBoundingBox(): Unable to find element with id='happiness' in the document.");
});
});
});
describe('useLockScroll', () => {
const renderTestHook = () => (
renderHook(() => useLockScroll())
);
let windowScrollSpy;
let addBodyClassSpy;
let removeBodyClassSpy;
let hook;
beforeEach(() => {
windowScrollSpy = jest.spyOn(window, 'scrollTo');
addBodyClassSpy = jest.spyOn(document.body.classList, 'add');
removeBodyClassSpy = jest.spyOn(document.body.classList, 'remove');
hook = renderTestHook();
});
it('should perform a scrollTo(0, 0) on mount', () => {
expect(windowScrollSpy).toHaveBeenCalledWith(0, 0);
});
it('should append a _search-no-scroll on mount to the document body', () => {
expect(addBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
});
it('should remove the _search-no-scroll on unmount', () => {
hook.unmount();
expect(removeBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
});
});
});

View File

@@ -0,0 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as CoursewareSearchToggle } from './CoursewareSearchToggle';
export { default as CoursewareSearch } from './CoursewareSearch';

View File

@@ -0,0 +1,117 @@
const Joi = require('joi');
const endpointSchema = Joi.object({
took: Joi.number(),
total: Joi.number(),
maxScore: Joi.number().allow(null),
results: Joi.array().items(Joi.object({
id: Joi.string(),
contentType: Joi.string(),
location: Joi.array().items(Joi.string()),
url: Joi.string(),
content: Joi.object({
displayName: Joi.string(),
htmlContent: Joi.string(),
transcriptEn: Joi.string(),
}),
}).unknown(true)).strict(),
}).unknown(true).strict();
const defaultType = 'text';
// Parses the search results in a convenient way.
export default function mapSearchResponse(response, searchKeywords = '') {
const { error, value: data } = endpointSchema.validate(response);
if (error) {
throw new Error('Error in server response:', error);
}
const keywords = searchKeywords ? searchKeywords.toLowerCase().split(' ') : [];
const {
took: ms,
total,
maxScore,
results: rawResults,
} = data;
const results = rawResults.map(result => {
const {
score,
data: {
id,
content: {
displayName,
htmlContent,
transcriptEn,
},
contentType,
location,
url,
},
} = result;
const type = contentType?.toLowerCase() || defaultType;
const content = htmlContent || transcriptEn || '';
const searchContent = content.toLowerCase();
let contentHits = 0;
if (keywords.length) {
keywords.forEach(word => {
contentHits += searchContent ? searchContent.toLowerCase().split(word).length - 1 : 0;
});
}
const title = displayName || contentType;
return {
id,
title,
type,
location,
url,
contentHits,
score,
};
});
const filters = rawResults.reduce((list, result) => {
const label = result?.data?.contentType;
if (!label) { return list; }
const key = label.toLowerCase();
const index = list.findIndex(i => i.key === key);
if (index === -1) {
return [
...list,
{
key,
label,
count: 1,
},
];
}
const newItem = { ...list[index] };
newItem.count++;
const newList = list.slice(0);
newList[index] = newItem;
return newList;
}, []);
filters.sort((a, b) => (a.key > b.key ? 1 : -1));
return {
results,
filters,
total,
maxScore,
ms,
};
}

View File

@@ -0,0 +1,52 @@
import { camelCaseObject } from '@edx/frontend-platform';
import mapSearchResponse from './map-search-response';
import mockedResponse from './test-data/mocked-response.json';
describe('mapSearchResponse', () => {
describe('when the response is correct', () => {
let response;
beforeEach(() => {
response = mapSearchResponse(camelCaseObject(mockedResponse));
});
it('should match snapshot', () => {
expect(response).toMatchSnapshot();
});
it('should match expected filters', () => {
const expectedFilters = [
{ key: 'capa', label: 'CAPA', count: 7 },
{ key: 'sequence', label: 'Sequence', count: 2 },
{ key: 'text', label: 'Text', count: 9 },
{ key: 'video', label: 'Video', count: 2 },
];
expect(response.filters).toEqual(expectedFilters);
});
});
describe('when the a keyword is provided', () => {
const searchText = 'Course';
it('should not count matches title', () => {
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText);
expect(response.results[0].contentHits).toBe(0);
});
it('should count matches on content', () => {
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText);
expect(response.results[1].contentHits).toBe(1);
});
it('should ignore capitalization', () => {
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText.toUpperCase());
expect(response.results[1].contentHits).toBe(1);
});
});
describe('when the response has a wrong format', () => {
it('should throw an error', () => {
expect(() => mapSearchResponse({ foo: 'bar' })).toThrow();
});
});
});

View File

@@ -0,0 +1,68 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
searchOpenAction: {
id: 'learn.coursewareSerch.openAction',
defaultMessage: 'Search within this course',
description: 'Aria-label for a button that will pop up Courseware Search.',
},
searchCloseAction: {
id: 'learn.coursewareSerch.closeAction',
defaultMessage: 'Close the search form',
description: 'Aria-label for a button that will close Courseware Search.',
},
searchModuleTitle: {
id: 'learn.coursewareSerch.searchModuleTitle',
defaultMessage: 'Search this course',
description: 'Title for the Courseware Search module.',
},
searchBarPlaceholderText: {
id: 'learn.coursewareSerch.searchBarPlaceholderText',
defaultMessage: 'Search',
description: 'Placeholder text for the Courseware Search input control',
},
loading: {
id: 'learn.coursewareSerch.loading',
defaultMessage: 'Searching...',
description: 'Screen reader text to use on the spinner while the search is performing.',
},
searchResultsNone: {
id: 'learn.coursewareSerch.searchResultsNone',
defaultMessage: 'No results found.',
description: 'Text to show when the Courseware Search found no results matching the criteria.',
},
searchResultsSingular: {
id: 'learn.coursewareSerch.searchResultsSingular',
defaultMessage: '1 match found for "{keyword}":',
description: 'Text to show when the Courseware Search found only one result matching the criteria.',
},
searchResultsPlural: {
id: 'learn.coursewareSerch.searchResultsPlural',
defaultMessage: '{total} matches found for "{keyword}":',
description: 'Text to show when the Courseware Search found multiple results matching the criteria.',
},
searchResultsError: {
id: 'learn.coursewareSerch.searchResultsError',
defaultMessage: 'There was an error on the search process. Please try again in a few minutes. If the problem persists, please contact the support team.',
description: 'Error message to show to the users when there\'s an error with the endpoint or the returned payload format.',
},
// These are translations for labeling the filters
'filter:none': {
id: 'learn.coursewareSerch.filter:none',
defaultMessage: 'All content',
description: 'Label for the search results filter that shows all content (no filter).',
},
'filter:text': {
id: 'learn.coursewareSerch.filter:text',
defaultMessage: 'Text',
description: 'Label for the search results filter that shows results with text content.',
},
'filter:video': {
id: 'learn.coursewareSerch.filter:video',
defaultMessage: 'Video',
description: 'Label for the search results filter that shows results with video content.',
},
});
export default messages;

View File

@@ -0,0 +1,548 @@
{
"took": 5,
"total": 29,
"max_score": 3.4545178,
"results": [
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Demo Course Overview"
},
"content_type": "Sequence",
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
"start_date": "1970-01-01T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Introduction",
"Demo Course Overview"
],
"excerpt": "Demo <b>Course</b> Overview",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
},
"score": 3.4545178
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Passing a Course",
"html_content": "Passing a COurse After the last assignment in a class has been due, you will see the entry in your student profile change to show progress toward generating your certificate. After the certificate generation process has completed, you will be able to download it from your profile page. "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"About Exams and Certificates",
"edX Exams",
"Passing a Course"
],
"excerpt": "Passing a <b>Course</b><span class=\"search-results-ellipsis\"></span>Passing a <b>COurse</b> After the last assignment in a class has been due,",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9"
},
"score": 3.4545178
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Passing a Course"
},
"content_type": "Sequence",
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"About Exams and Certificates",
"edX Exams",
"Passing a Course"
],
"excerpt": "Passing a <b>Course</b>",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff"
},
"score": 3.4545178
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Text Input",
"capa_content": " Here's a very simple example of a text input question. Depending on the course you may have to observe special text requirements for dates, case sensitivity, etc. Which country contains Paris as its capital? "
},
"content_type": "CAPA",
"problem_types": [
"stringresponse"
],
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Text input"
],
"excerpt": "the <b>course</b> you may have to observe special text requirements for",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02"
},
"score": 1.5874016
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Pointing on a Picture",
"capa_content": " Some course questions may show you an image and ask that you click on it to answer a question. Try this example. (If you are correct you will see our famous green check mark.) Which animal is a kitten? "
},
"content_type": "CAPA",
"problem_types": [
"imageresponse"
],
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Pointing on a Picture"
],
"excerpt": " Some <b>course</b> questions may show you an image and ask that you click on",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c"
},
"score": 1.5499392
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Getting Answers",
"capa_content": " In some courses a \"show answer\" button might appear below a question. When you click on this button, you can see the correct answer (with an explanation) that would receive full credit. How much does it cost to take an edX course? Enter the number of dollars. "
},
"content_type": "CAPA",
"problem_types": [
"numericalresponse"
],
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"About Exams and Certificates",
"edX Exams",
"Getting Answers"
],
"excerpt": " In some <b>course</b>s a \"show answer\" button might appear below a question.",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4"
},
"score": 1.5003732
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Welcome!",
"transcript_en": " ERIC: Hi, and welcome to the edX demonstration course. I'm Eric, and I'm here to help you get a better understanding of how fun and easy it is to take an edX course. So, let's get started. Let me show you how all the parts work together. If at any time you want to skip this video and get a firsthand experience of the demonstration course, all you have to do is click week one to the left. Don't worry, I won't be offended. Let's first look along the top of the page. This area's called the navigation bar. Click on Courseware to interact with your course. Course Info contains course announcements and updates from the course staff. If your course has digital textbooks, this is where you'll find them. Discussion is where you can communicate with the fellow students on topics and projects, and even occasionally with the course staff. When available, the course Wiki acts as a knowledge base for your course. It's a helpful resource. Clicking on Progress will reveal how well you're doing in your studies and exams. When you take the demo course, we'll provide you with a simple progress report matching your results. Let's look at the left column now. The left side of the Courseware screen contains a course navigation bar starting from the top down. Many courses start with an overview of edX and an introduction to the course. Below the overview are segments of the course, which are released as the course progresses. Typically, an edX course is delivered in week by week segments, and have lessons and homeworks you need to complete. Many courses are 10 to 12 weeks long. We made this demonstration course three weeks for simplicity. Let's now look at the learning sequence. Each item in the left column reveals a corresponding learning sequence. Work your way from left to right. Learning sequences can contain lectures, exercises, and interactive lessons that you can complete on your own schedule. Next, let's discover what makes edX fun and unique, its interactivity. edX prides itself on its interactive lessons, which can include demonstrations, visualizations, and virtual environments. You can try out some in the demo course. Interactive lessons are often graded and contribute to your final grade. While the edX platform also supports more traditional question formats like multiple choice, our classes also test your understanding by allowing you to use labs and simulators, and even asking you to write an essay. Example of these graded interactions are in the demo course. You can see how the questions the course uses for gauging your learning process can even be auto graded, or detailed feedback given in real time. So while an edX course might be rigorous, the tools and visualizations are really fun and truly interactive. Finally, there are many ways successful students like to you interact to get the most out of a course. Beyond the discussion forums, you can meet and engage with fellow classmates through a local meet up-- which we highly recommend-- a Google Hangout, or even invite students to join you via Twitter, Facebook, or other social networks. It's a proven fact that if you engage with others while taking a course, you're more likely to succeed. Now that you've seen how easy it is to take an edX course, experience this demonstration course. Firsthand all you have to do is click on week one to the left and you can get started. "
},
"content_type": "Video",
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
"start_date": "1970-01-01T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences"
],
"excerpt": " ERIC: Hi, and welcome to the edX demonstration <b>course</b>. I'm Eric, and",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd"
},
"score": 1.4792063
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Multiple Choice Questions",
"capa_content": " Many edX courses have homework or exercises you need to complete. Notice the clock image to the left? That means this homework or exercise needs to be completed for you to pass the course. (This can be a bit confusing; the exercise may or may not have a due date prior to the end of the course.) We\u2019ve provided eight (8) examples of how a professor might ask you questions. While the multiple choice question types below are somewhat standard, explore the other question types in the sequence above, like the formula builder- try them all out. As you go through the question types, notice how edX gives you immediate feedback on your responses - it really helps in the learning process. What color is the open ocean on a sunny day? 'yellow','blue','green' Which piece of furniture is built for sitting? a table a desk a chair a bookshelf Which of the following are musical instruments? a piano a tree a guitar a window "
},
"content_type": "CAPA",
"problem_types": [
"multiplechoiceresponse",
"choiceresponse",
"optionresponse"
],
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Multiple Choice Questions"
],
"excerpt": " Many edX <b>course</b>s have homework or exercises you need to complete.",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4"
},
"score": 1.4341705
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Numerical Input",
"capa_content": " Some course questions ask that you insert numbers into web-text fields, and your answers can be judged exactly - or approximately - according to the question. Note that the edX system uses a period to indicate decimals, so fifteen and three quarters is written \"15.75\", not \"15,75\". Enter the numerical value of Pi: Enter the approximate value of 502*9: Enter the number of fingernails on a healthy human hand. For the purposes of this question, please consider the thumb as a finger: "
},
"content_type": "CAPA",
"problem_types": [
"numericalresponse"
],
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Numerical Input"
],
"excerpt": " Some <b>course</b> questions ask that you insert numbers into web-text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974"
},
"score": 1.2987298
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Connecting a Circuit and a Circuit Diagram",
"transcript_en": "SPEAKER 1: What we see here-- a mess. OK? What we have is a voltmeter and an amp meter. The amp meter measures current, the voltmeter measures voltage of course. And we're measuring the voltage across the light bulb-- the lamp. And we're measuring the current into the lamp. PETER: So we've got a voltmeter measuring across the-- volts meter-- across there. And then we've got an amp reader measuring into. SPEAKER 1: Right. Exactly. Now, we're going to connect that to a battery. The three cell battery that you've seen before. And we're going to see, of course, that the light bulb lit up. And the current we measure is 122 milliamperes going into the light bulb. 122 milliamperes. And the voltage across is from the plus to minus, 4.31 volts. OK? Now, we can do another experiment. Notice how the light bulb is lit up and how much it's lit, approximately. Now, I'm going to reverse the battery so that we connect the battery in the opposite polarity. OK. Go ahead, Peter. PETER: You connect it, I will draw. SPEAKER 1: OK, I'm just doing it. PETER: So I'll swap these. SPEAKER 1: Yes, sir. OK. And what we observe is basically the amp meter is measuring 122 milliamperes in the negative direction. So that means it's measuring the current into the light bulb-- because I've not changed the orientation with respect to the light bulb-- of minus 122 milliamperes. And the voltage across the light bulb, from here to here, is minus 4.29 volts. PETER: Sorry, that's a minus? SPEAKER 1: Yes. PETER: So if we look at the power in the first case, 122 milliamps times 4.31 volts, we get 526 milliwatts. SPEAKER 1: Yep. PETER: If we measure the power in this case over here, minus 122 milliamps times minus 4.29 volts, we get approximately the same thing. So I'm going to round it off to, let's say best guess, 524, maybe 23 or something. No less. SPEAKER 1: OK. PETER: So this is equal to that within measurement error. SPEAKER 1: And of course, you see the power is the power going into the light bulb and coming out as light and heat. OK? We have arranged our measurements by having these associated reference directions, so that this is plus and that's minus, and that the current always goes into the terminal that we label with plus. That always means that the power we measure by multiplying these two numbers is the power going into this device. PETER: So this light bulb is dissipating 524 milliwatts. If we were to do the same calculation for the battery, so current would be going to the positive terminal, we would-- SPEAKER 1: Well, you have to measure it then from there to there. PETER: Yeah. Plus, minus. That's what we're doing. So this would be 4.29 volts. The current would still be minus 122 milliamps. The current's moving in a loop, so here is the same as here, but the signs are swapped. That same calculation would give us minus 524 milliwatts. And that's because the battery is outputting power, whereas the light bulb is dissipating power. SPEAKER 1: Think about it as, if we're measuring the power entering the battery, it's minus 524 milliwatts. OK? That's the way to think about it. This always gives you the power entering that element."
},
"content_type": "Video",
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
"start_date": "2013-02-05T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Video Presentation Styles"
],
"excerpt": "measures voltage of <b>course</b>. And we're measuring the voltage across the",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6"
},
"score": 1.1870136
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "",
"capa_content": " We are searching for the smallest monthly payment such that we can pay off the entire balance of a loan within a year. The following values might be useful when writing your solution Monthly interest rate = (Annual interest rate) / 12 Monthly payment lower bound = Balance / 12 Monthly payment upper bound = (Balance x (1 + Monthly interest rate)12) / 12 The following variables contain values as described below: balance - the outstanding balance on the credit card annualInterestRate - annual interest rate as a decimal Write a program that uses these bounds and bisection search (for more info check out the Wikipedia page on bisection search) to find the smallest monthly payment to the cent such that we can pay off the debt within a year. Note that if you do not use bisection search, your code will not run - your code only has 30 seconds to run on our servers. If you get a message that states \"Your submission could not be graded. Please recheck your submission and try again. If the problem persists, please notify the course staff.\", check to be sure your code doesn't take too long to run. The code you paste into the following box should not specify the values for the variables balance or annualInterestRate - our test code will define those values before testing your submission. monthlyInterestRate = annualInterestRate/12 lowerBound = balance/12 upperBound = (balance * (1+annualInterestRate/12)**12)/12 originalBalance = balance # Keep testing new payment values # until the balance is +/- $0.02 while abs(balance) &gt; .02: # Reset the value of balance to its original value balance = originalBalance # Calculate a new monthly payment value from the bounds payment = (upperBound - lowerBound)/2 + lowerBound # Test if this payment value is sufficient to pay off the # entire balance in 12 months for month in range(12): balance -= payment balance *= 1+monthlyInterestRate # Reset bounds based on the final value of balance if balance &gt; 0: # If the balance is too big, need higher payment # so we increase the lower bound lowerBound = payment else: # If the balance is too small, we need a lower # payment, so we decrease the upper bound upperBound = payment # When the while loop terminates, we know we have # our answer! print(\"Lowest Payment:\", round(payment, 2)) {\"grader\": \"ps02/bisect/grade_bisect.py\"} Note: Depending on where, and how frequently, you round during this function, your answers may be off a few cents in either direction. Try rounding as few times as possible in order to increase the accuracy of your result. Hints Test Cases to test your code with. Be sure to test these on your own machine - and that you get the same output! - before running your code on this webpage! Note: The automated tests are lenient - if your answers are off by a few cents in either direction, your code is OK. Test Cases: Test Case 1: balance = 320000 annualInterestRate = 0.2 Result Your Code Should Generate: ------------------- Lowest Payment: 29157.09 Test Case 2: balance = 999999 annualInterestRate = 0.18 Result Your Code Should Generate: ------------------- Lowest Payment: 90325.07 The autograder says, \"Your submission could not be graded.\" Help! If the autograder gives you the following message: Your submission could not be graded. Please recheck your submission and try again. If the problem persists, please notify the course staff. Don't panic! There are a few things that might be wrong with your code that you should check out. The number one reason this message appears is because your code timed out. You only get 30 seconds of computation time on our servers. If your code times out, you probably have an infinite loop. What to do? The number 1 thing to do is that you need to run this code in your own local environment. Your code should print one line at the end of the loop. If your code never prints anything out - you have an infinite loop! To debug your infinite loop - check your loop conditional. When will it stop? Try inserting print statements inside your loop that prints out information (like variables) - are you incrementing or decrementing your loop counter correctly? Search the forum for people with similar issues. If your search turns up nothing, make a new post and paste in your loop conditional for others to help you out with. Please don't email the course staff unless your code legitimately works and prints out the correct answers in your local environment. In that case, please email your code file, a screenshot of the code printing out the correct answers in your local environment, and a screenshot of the exact same code not working on the tutor. The course staff is otherwise unable to help debug your problem set via email - we can only address platform issues. "
},
"content_type": "CAPA",
"problem_types": [
"coderesponse"
],
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 2: Get Interactive",
"Homework - Labs and Demos",
"Code Grader"
],
"excerpt": "notify the <b>course</b> staff.\", check to be sure your code doesn't take too",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader"
},
"score": 1.0107487
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Interactive Questions",
"capa_content": " Most courses have interactive questions that test your knowledge like the one below. They can be part of a learning sequence or an exam. Notice the visual feedback. Go ahead, try it out! Questions which are part of assignments or exams may have due dates - the last possible time you can submit an assignment for grading. Once this time has passed, you will not be able to get credit for any incomplete problems in the assignment. If an assignment has a due date, you can see the due date in the sidebar. (This demo course does not have any assignments with due dates.) If no due date is displayed, the assignment can be turned in at any time. All assignment due dates are displayed in the time zone that you select in your account settings. If you do not specify a time zone, assignment due dates display in your browser's time zone. What kinds of late policies does edX allow? late penalties instructor forgiveness late time budget none of the above "
},
"content_type": "CAPA",
"problem_types": [
"choiceresponse"
],
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
"start_date": "2013-02-05T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Interactive Questions"
],
"excerpt": " Most <b>course</b>s have interactive questions that test your knowledge like",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618"
},
"score": 0.96387196
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Blank HTML Page",
"html_content": "Welcome to the Open edX Demo Course Introduction. This is where you can explore how to take an edX course (like this one). Most courses have an \"intro\" video that shows you how it all works. You can watch the introduction video (below) or scroll though the course studies and assignments using the toolbar (above). Just for fun, we'll keep track of your work in this demo course, and show you your progress in the toolbar just like in a real course. Watch the overview video (below), then click on \"Example Week One\" in the left hand navigation to get started. "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
"start_date": "1970-01-01T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences"
],
"excerpt": "Welcome to the Open edX Demo <b>Course</b> Introduction. This is where you",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
},
"score": 0.8844358
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Discussion Forums",
"html_content": "Discussion FORUMS The discussion forum for each course is found at the top of the course page. You might come across a subset of the discussion forum inside the course (see below), where you can talk with fellow students about the course in context. Go ahead and be social! Make your first post in this demo course. Keep an eye out for posts with a green check mark. The green check means the post has been recognized by a staff member or forum moderator as a great post. You can also actively upvote a post. Others can search on user \u201cupvoted\u201d posts. They tend to be very helpful. "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
"start_date": "1978-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Discussion Forums"
],
"excerpt": "Discussion FORUMS The discussion forum for each <b>course</b> is found at the",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7"
},
"score": 0.8803684
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Overall Grade",
"html_content": " OVERALL GRADE PERFORMANCE The progress tab (selectable near the top of each page in your course) shows your performance. Click on it now, and you will see how you're doing in this demo course. The bar chart shows the overall percentage that you have earned on each assignment in the course, and how each of those assignments combine into your overall grade. Further down the page is a detailed breakdown of your score on every graded question in the class. You might notice that some of your assignments on the bar chart show an 'x'. The 'x's indicate the assignments that the edX system will NOT be counting toward your final grade, according to the course grading. The 'x's go to the assignments that you scored the lowest on. Each course has its own percentage cutoff for a Certificate of Mastery. You can see where those cutoffs are by looking at the vertical description. In this demo, a \"pass\" is considered 60%. When you \"pass\" a live edX course, you will receive a certificate after the class has closed. Sorry - the demo course does not grant certificates! "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"About Exams and Certificates",
"edX Exams",
"Overall Grade Performance"
],
"excerpt": "of each page in your <b>course</b>) shows your performance. Click on it now,",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c"
},
"score": 0.87981963
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Blank HTML Page",
"html_content": "Find Your Study Buddy Working with other students offline can help you get the most out of an online course and even increase the likelihood you will successfully complete the course. So, your homework is to find a study buddy. The course specific discussion forums are a great place to find neighbors or even new friends to invite to a Meetup you are looking to organize or even a virtual Google Hangout. "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
"start_date": "1978-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Homework - Find Your Study Buddy"
],
"excerpt": "get the most out of an online <b>course</b> and even increase the likelihood",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339"
},
"score": 0.84284115
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Find Your Study Buddy",
"html_content": "Find Your Study Buddy Working with other students offline can help you get the most out of an online course and even increase the likelihood you will successfully complete the course. So, your homework is to find a study buddy. The course specific discussion forums are a great place to find neighbors or even new friends to invite to a Meetup you are looking to organize or even a virtual Google Hangout. "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
"start_date": "2013-02-05T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 3: Be Social",
"Homework - Find Your Study Buddy",
"Homework - Find Your Study Buddy"
],
"excerpt": "get the most out of an online <b>course</b> and even increase the likelihood",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5"
},
"score": 0.84284115
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "Be Social",
"html_content": "Be SOCIAL A big part of learning online includes \u201cbeing social.\u201d We encourage all students to communicate within the course discussion forums \u2013 a great place to connect with other students and to get support from the course staff. Some students and professors also engage through other social mediums like Meetup or Facebook. Recent research has found that if you take a class with a friend, or engage socially with other learners while taking a course, there is a higher likelihood that you will complete a course. If you haven\u2019t already, consider finding a study buddy! Check out more information about the discussion forum by navigating to the next item in this learning sequence. In the discussion forums, remember to be polite and respectful. Simply put, treat others the way you want to be treated. "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
"start_date": "1978-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Be Social"
],
"excerpt": "encourage all students to communicate within the <b>course</b> discussion",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0"
},
"score": 0.84210813
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "EdX Exams",
"html_content": " EDX EXAMS Not all edX courses have exams; many do, but not all. When choosing a course, it's a good idea to check the exam and study requirements, as well as any prerequisites. Of course - you can \"audit\" any edX course, which means you can study alongside other students using the same content, tools and materials, but you're not focused on grades and might skip the exams and assignments. Follow this learning sequence via the links above to understand more about how we grade your work and track your progress. "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
"start_date": "2013-02-05T00:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"About Exams and Certificates",
"edX Exams",
"EdX Exams"
],
"excerpt": " EDX EXAMS Not all edX <b>course</b>s have exams; many do, but not all. When",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530"
},
"score": 0.8306555
},
{
"_index": "courseware_content",
"_type": "_doc",
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
"data": {
"course": "course-v1:edX+DemoX+Demo_Course",
"org": "edX",
"content": {
"display_name": "When Are Your Exams? ",
"html_content": "WHEN ARE YOUR Exams? Every course treats the timing on its exams differently, and you should be really careful to pay attention to any announcements about exam timing that your course makes. "
},
"content_type": "Text",
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
"start_date": "2013-02-05T05:00:00+00:00",
"content_groups": null,
"course_name": "Demonstration Course",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"When Are Your Exams? "
],
"excerpt": "WHEN ARE YOUR Exams? Every <b>course</b> treats the timing on its exams",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
},
"score": 0.82610154
}
],
"access_denied_count": 0
}

View File

@@ -9,7 +9,7 @@ Factory.define('courseHomeMetadata')
is_self_paced: false,
is_enrolled: false,
is_staff: false,
can_load_courseware: true,
can_view_certificate: true,
celebrations: null,
course_access: {
additional_context_user_message: null,
@@ -34,91 +34,89 @@ Factory.define('courseHomeMetadata')
currency_symbol: '$',
},
})
.attr(
'tabs', ['id', 'host'], (id, host) => [
Factory.build(
'tab',
{
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
},
{
courseId: id,
host,
path: 'course/',
},
),
Factory.build(
'tab',
{
title: 'Discussion',
priority: 1,
slug: 'discussion',
type: 'discussion',
},
{
courseId: id,
host,
path: 'discussion/forum/',
},
),
Factory.build(
'tab',
{
title: 'Wiki',
priority: 2,
slug: 'wiki',
type: 'wiki',
},
{
courseId: id,
host,
path: 'course_wiki',
},
),
Factory.build(
'tab',
{
title: 'Progress',
priority: 3,
slug: 'progress',
type: 'progress',
},
{
courseId: id,
host,
path: 'progress',
},
),
Factory.build(
'tab',
{
title: 'Instructor',
priority: 4,
slug: 'instructor',
type: 'instructor',
},
{
courseId: id,
host,
path: 'instructor',
},
),
Factory.build(
'tab',
{
title: 'Dates',
priority: 5,
slug: 'dates',
type: 'dates',
},
{
courseId: id,
host,
path: 'dates',
},
),
],
);
.attr('tabs', ['id', 'host'], (id, host) => [
Factory.build(
'tab',
{
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
},
{
courseId: id,
host,
path: 'course/',
},
),
Factory.build(
'tab',
{
title: 'Discussion',
priority: 1,
slug: 'discussion',
type: 'discussion',
},
{
courseId: id,
host,
path: 'discussion/forum/',
},
),
Factory.build(
'tab',
{
title: 'Wiki',
priority: 2,
slug: 'wiki',
type: 'wiki',
},
{
courseId: id,
host,
path: 'course_wiki',
},
),
Factory.build(
'tab',
{
title: 'Progress',
priority: 3,
slug: 'progress',
type: 'progress',
},
{
courseId: id,
host,
path: 'progress',
},
),
Factory.build(
'tab',
{
title: 'Instructor',
priority: 4,
slug: 'instructor',
type: 'instructor',
},
{
courseId: id,
host,
path: 'instructor',
},
),
Factory.build(
'tab',
{
title: 'Dates',
priority: 5,
slug: 'dates',
type: 'dates',
},
{
courseId: id,
host,
path: 'dates',
},
),
]);

View File

@@ -28,6 +28,7 @@ Factory.define('outlineTabData')
upgrade_url: `${host}/dashboard`,
}))
.attrs({
course_access_redirect: false,
has_scheduled_content: null,
access_expiration: null,
can_show_upgrade_sock: false,
@@ -35,7 +36,6 @@ Factory.define('outlineTabData')
cert_status: null,
cert_web_view_url: null,
certificate_available_date: null,
download_url: null,
},
course_goals: {
goal_options: [],

View File

@@ -6,6 +6,7 @@ Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -18,10 +19,13 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
@@ -324,6 +328,7 @@ Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -336,10 +341,13 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
@@ -411,7 +419,6 @@ Object {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
"downloadUrl": null,
},
"courseBlocks": Object {
"courses": Object {
@@ -445,7 +452,6 @@ Object {
"effortTime": 15,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true,
"title": "Title of Sequence",
@@ -522,6 +528,7 @@ Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -534,10 +541,13 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,

View File

@@ -148,12 +148,9 @@ export function normalizeOutlineBlocks(courseId, blocks) {
effortTime: block.effort_time,
icon: block.icon,
id: block.id,
legacyWebUrl: block.legacy_web_url,
// The presence of an legacy URL for the sequence indicates that we want this
// sequence to be a clickable link in the outline (even though, if the new
// courseware experience is active, we will ignore `legacyWebUrl` and build a
// link to the MFE ourselves).
showLink: !!block.legacy_web_url,
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
showLink: !!block.lms_web_url,
title: block.display_name,
};
break;
@@ -207,16 +204,18 @@ export async function getDatesTabData(courseId) {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
const httpErrorStatus = error?.response?.status;
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
}
@@ -266,7 +265,7 @@ export async function getProgressTabData(courseId, targetUserId) {
return camelCasedData;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
const httpErrorStatus = error?.response?.status;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
@@ -276,6 +275,12 @@ export async function getProgressTabData(courseId, targetUserId) {
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
}
@@ -297,6 +302,20 @@ export async function getProctoringInfoData(courseId, username) {
}
}
export async function getLiveTabIframe(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return {};
}
throw error;
}
}
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
@@ -314,22 +333,23 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
let { tabData } = {};
let requestTime = Date.now();
let responseTime = requestTime;
const requestTime = Date.now();
let tabData;
try {
requestTime = Date.now();
tabData = await getAuthenticatedHttpClient().get(url);
responseTime = Date.now();
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
const httpErrorStatus = error?.response?.status;
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
const responseTime = Date.now();
const {
data,
headers,
@@ -354,7 +374,7 @@ export async function getOutlineTabData(courseId) {
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html;
const welcomeMessageHtml = data.welcome_message_html || '';
return {
accessExpiration,
@@ -425,3 +445,20 @@ export async function unsubscribeFromCourseGoal(token) {
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}
export async function getCoursewareSearchEnabledFlag(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return { enabled: data.enabled || false };
}
export async function searchCourseContentFromAPI(courseId, searchKeyword, options = {}) {
const defaults = { page: 0, limit: 20 };
const { page, limit } = { ...defaults, ...options };
const url = new URL(`${getConfig().LMS_BASE_URL}/search/${courseId}`);
const formData = `search_string=${searchKeyword}&page_size=${limit}&page_index=${page}`;
const response = await getAuthenticatedHttpClient().post(url.href, formData);
return camelCaseObject(response);
}

View File

@@ -1,6 +1,6 @@
import { Pact, Matchers } from '@pact-foundation/pact';
import path from 'path';
import { mergeConfig, getConfig } from '@edx/frontend-platform';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import {
getCourseHomeCourseMetadata,
@@ -14,8 +14,8 @@ import {
const {
somethingLike: like, term, boolean, string, eachLike,
} = Matchers;
const provider = new Pact({
} = MatchersV3;
const provider = new PactV3({
consumer: 'frontend-app-learning',
provider: 'lms',
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
@@ -28,196 +28,193 @@ const provider = new Pact({
describe('Course Home Service', () => {
beforeAll(async () => {
initializeMockApp();
await provider
.setup()
.then((options) => mergeConfig({
LMS_BASE_URL: `http://localhost:${options.port}`,
}, 'Custom app config for pact tests'));
mergeConfig({
LMS_BASE_URL: 'http://localhost:8081',
}, 'Custom app config for pact tests');
});
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
describe('When a request to fetch tab is made', () => {
it('returns tab data for a course_id', async () => {
await provider.addInteraction({
state: `Tab data exists for course_id ${courseId}`,
uponReceiving: 'a request to fetch tab',
withRequest: {
method: 'GET',
path: `/api/course_home/course_metadata/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
can_show_upgrade_sock: boolean(false),
verified_mode: like({
access_expiration_date: null,
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
can_load_courseware: boolean(true),
celebrations: like({
first_section: false,
streak_length_to_celebrate: null,
streak_discount_enabled: false,
}),
course_access: {
has_access: boolean(true),
error_code: null,
developer_message: null,
user_message: null,
additional_context_user_message: null,
user_fragment: null,
setTimeout(() => {
provider.addInteraction({
state: `Tab data exists for course_id ${courseId}`,
uponReceiving: 'a request to fetch tab',
withRequest: {
method: 'GET',
path: `/api/course_home/course_metadata/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
can_show_upgrade_sock: boolean(false),
verified_mode: like({
access_expiration_date: null,
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
celebrations: like({
first_section: false,
streak_length_to_celebrate: null,
streak_discount_enabled: false,
}),
course_access: {
has_access: boolean(true),
error_code: null,
developer_message: null,
user_message: null,
additional_context_user_message: null,
user_fragment: null,
},
course_id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
is_enrolled: boolean(true),
is_self_paced: boolean(false),
is_staff: boolean(true),
number: string('DemoX'),
org: string('edX'),
original_user_is_staff: boolean(true),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
tabs: eachLike({
tab_id: 'courseware',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
}),
title: string('Demonstration Course'),
username: string('edx'),
},
course_id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
is_enrolled: boolean(true),
is_self_paced: boolean(false),
is_staff: boolean(true),
number: string('DemoX'),
org: string('edX'),
original_user_is_staff: boolean(true),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
tabs: eachLike({
tab_id: 'courseware',
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
celebrations: {
firstSection: false,
streakLengthToCelebrate: null,
streakDiscountEnabled: false,
},
courseAccess: {
hasAccess: true,
errorCode: null,
developerMessage: null,
userMessage: null,
additionalContextUserMessage: null,
userFragment: null,
},
courseId: 'course-v1:edX+DemoX+Demo_Course',
isEnrolled: true,
isMasquerading: false,
isSelfPaced: false,
isStaff: true,
number: 'DemoX',
org: 'edX',
originalUserIsStaff: true,
start: '2013-02-05T05:00:00Z',
tabs: [
{
slug: 'outline',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
}),
title: string('Demonstration Course'),
username: string('edx'),
},
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
canLoadCourseware: true,
celebrations: {
firstSection: false,
streakLengthToCelebrate: null,
streakDiscountEnabled: false,
},
courseAccess: {
hasAccess: true,
errorCode: null,
developerMessage: null,
userMessage: null,
additionalContextUserMessage: null,
userFragment: null,
},
courseId: 'course-v1:edX+DemoX+Demo_Course',
isEnrolled: true,
isMasquerading: false,
isSelfPaced: false,
isStaff: true,
number: 'DemoX',
org: 'edX',
originalUserIsStaff: true,
start: '2013-02-05T05:00:00Z',
tabs: [
{
slug: 'outline',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
},
],
title: 'Demonstration Course',
username: 'edx',
};
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData);
},
],
title: 'Demonstration Course',
username: 'edx',
};
const response = getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData);
}, 100);
});
});
describe('When a request to fetch dates tab is made', () => {
it('returns course date blocks for a course_id', async () => {
await provider.addInteraction({
state: `course date blocks exist for course_id ${courseId}`,
uponReceiving: 'a request to fetch dates tab',
withRequest: {
method: 'GET',
path: `/api/course_home/dates/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
dates_banner_info: like({
missed_deadlines: false,
content_type_gating_enabled: false,
missed_gated_content: false,
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
course_date_blocks: eachLike({
assignment_type: null,
setTimeout(() => {
provider.addInteraction({
state: `course date blocks exist for course_id ${courseId}`,
uponReceiving: 'a request to fetch dates tab',
withRequest: {
method: 'GET',
path: `/api/course_home/dates/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
dates_banner_info: like({
missed_deadlines: false,
content_type_gating_enabled: false,
missed_gated_content: false,
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
course_date_blocks: eachLike({
assignment_type: null,
complete: null,
date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
date_type: term({
generate: 'verified-upgrade-deadline',
matcher: dateTypeRegex,
}),
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learner_has_access: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
link_text: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extra_info: null,
first_component_block_id: '',
}),
has_ended: boolean(false),
learner_is_full_access: boolean(true),
user_timezone: null,
},
},
});
const camelCaseResponse = {
datesBannerInfo: {
missedDeadlines: false,
contentTypeGatingEnabled: false,
missedGatedContent: false,
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
courseDateBlocks: [
{
assignmentType: null,
complete: null,
date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
date_type: term({
generate: 'verified-upgrade-deadline',
matcher: dateTypeRegex,
}),
date: '2013-02-05T05:00:00Z',
dateType: 'verified-upgrade-deadline',
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learner_has_access: true,
learnerHasAccess: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
link_text: 'Upgrade to Verified Certificate',
linkText: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extra_info: null,
first_component_block_id: '',
}),
has_ended: boolean(false),
learner_is_full_access: boolean(true),
user_timezone: null,
},
},
});
const camelCaseResponse = {
datesBannerInfo: {
missedDeadlines: false,
contentTypeGatingEnabled: false,
missedGatedContent: false,
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
courseDateBlocks: [
{
assignmentType: null,
complete: null,
date: '2013-02-05T05:00:00Z',
dateType: 'verified-upgrade-deadline',
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learnerHasAccess: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
linkText: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extraInfo: null,
firstComponentBlockId: '',
},
],
hasEnded: false,
learnerIsFullAccess: true,
userTimezone: null,
};
const response = await getDatesTabData(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
extraInfo: null,
firstComponentBlockId: '',
},
],
hasEnded: false,
learnerIsFullAccess: true,
userTimezone: null,
};
const response = getDatesTabData(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
}, 100);
});
});
});

View File

@@ -21,6 +21,18 @@ describe('Data layer integration tests', () => {
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const courseHomeAccessDeniedMetadata = Factory.build(
'courseHomeMetadata',
{
id: courseId,
course_access: {
has_access: false,
error_code: 'bad codes',
additional_context_user_message: 'your Codes Are BAD',
},
},
);
let store;
beforeEach(() => {
@@ -55,16 +67,40 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 401 || errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
axiosMock.onGet(outlineUrl).networkError();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
@@ -75,8 +111,6 @@ describe('Data layer integration tests', () => {
it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
@@ -84,8 +118,31 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
describe('Test fetchProgressTab', () => {
@@ -113,7 +170,14 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
});
it('Should handle the url including a targetUserId', async () => {
@@ -129,6 +193,19 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.targetUserId).toEqual(2);
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
const progressUrl = `${progressBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(progressUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
},
);
});
describe('Test saveCourseGoal', () => {
@@ -173,4 +250,36 @@ describe('Data layer integration tests', () => {
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
});
});
describe('Test fetchCoursewareSearchSettings', () => {
it('Should return enabled as true when enabled', async () => {
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
axiosMock.onGet(apiUrl).reply(200, { enabled: true });
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
expect(enabled).toBe(true);
});
it('Should return enabled as false when disabled', async () => {
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
axiosMock.onGet(apiUrl).reply(200, { enabled: false });
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
expect(enabled).toBe(false);
});
it('Should return enabled as false on error', async () => {
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
axiosMock.onGet(apiUrl).networkError();
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
expect(enabled).toBe(false);
});
});
});

View File

@@ -15,6 +15,7 @@ const slice = createSlice({
toastBodyText: null,
toastBodyLink: null,
toastHeader: '',
showSearch: false,
},
reducers: {
fetchProctoringInfoResolved: (state) => {
@@ -47,6 +48,9 @@ const slice = createSlice({
state.toastBodyText = linkText;
state.toastHeader = header;
},
setShowSearch: (state, { payload }) => {
state.showSearch = payload;
},
},
});
@@ -57,6 +61,7 @@ export const {
fetchTabRequest,
fetchTabSuccess,
setCallToActionToast,
setShowSearch,
} = slice.actions;
export const {

View File

@@ -11,10 +11,13 @@ import {
postWeeklyLearningGoal,
postDismissWelcomeMessage,
postRequestCert,
getLiveTabIframe,
getCoursewareSearchEnabledFlag,
searchCourseContentFromAPI,
} from './api';
import {
addModel,
addModel, updateModel,
} from '../../generic/model-store';
import {
@@ -25,6 +28,8 @@ import {
setCallToActionToast,
} from './slice';
import mapSearchResponse from '../courseware-search/map-search-response';
const eventTypes = {
POST_EVENT: 'post_event',
};
@@ -32,46 +37,38 @@ const eventTypes = {
export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([
getCourseHomeCourseMetadata(courseId, 'outline'),
getTabData(courseId, targetUserId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
try {
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadata,
},
}));
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
if (tabDataResult) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult.value,
...tabDataResult,
},
}));
} else {
logError(tabDataResult.reason);
}
// Disable the access-denied path for now - it caused a regression
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId }));
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId, targetUserId }));
} else {
dispatch(fetchTabFailure({ courseId }));
} else if (tabDataResult || !getTabData) {
dispatch(fetchTabSuccess({
courseId,
targetUserId,
}));
}
});
} catch (e) {
dispatch(fetchTabFailure({ courseId }));
logError(e);
}
};
}
@@ -87,6 +84,14 @@ export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData);
}
export function fetchLiveTab(courseId) {
return fetchTab(courseId, 'live', getLiveTabIframe);
}
export function fetchDiscussionTab(courseId) {
return fetchTab(courseId, 'discussion');
}
export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
}
@@ -138,3 +143,70 @@ export function processEvent(eventData, getTabData) {
}
};
}
export async function fetchCoursewareSearchSettings(courseId) {
try {
const { enabled } = await getCoursewareSearchEnabledFlag(courseId);
return { enabled };
} catch (e) {
return { enabled: false };
}
}
export function searchCourseContent(courseId, searchKeyword) {
return async (dispatch) => {
const start = new Date();
dispatch(addModel({
modelType: 'contentSearchResults',
model: {
id: courseId,
searchKeyword,
results: [],
errors: undefined,
loading: true,
},
}));
let data;
let curatedResponse;
let errors;
try {
({ data } = await searchCourseContentFromAPI(courseId, searchKeyword));
curatedResponse = mapSearchResponse(data, searchKeyword);
} catch (e) {
// TODO: Remove when publishing to prod. Just temporary for performance debugging.
// eslint-disable-next-line no-console
console.error('Error on Courseware Search: ', e.message);
errors = e.message;
}
dispatch(updateModel({
modelType: 'contentSearchResults',
model: {
...curatedResponse,
id: courseId,
searchKeyword,
errors,
loading: false,
},
}));
const end = new Date();
const clientMs = (end - start);
const {
took, total, maxScore, accessDeniedCount,
} = data;
// TODO: Remove when publishing to prod. Just temporary for performance debugging.
// eslint-disable-next-line no-console
console.table({
'Search Keyword': searchKeyword,
'Client time (ms)': clientMs,
'Server time (ms)': took,
'Total matches': total,
'Max score': maxScore,
'Access denied count': accessDeniedCount,
});
};
}

View File

@@ -9,14 +9,12 @@ import Timeline from './timeline/Timeline';
import { fetchDatesTab } from '../data';
import { useModel } from '../../generic/model-store';
/** [MM-P2P] Experiment */
import { initDatesMMP2P } from '../../experiments/mm-p2p';
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
function DatesTab({ intl }) {
const DatesTab = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -30,9 +28,6 @@ function DatesTab({ intl }) {
courseDateBlocks,
} = useModel('dates', courseId);
/** [MM-P2P] Experiment */
const mmp2p = initDatesMMP2P(courseId);
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const logUpgradeLinkClick = () => {
@@ -51,8 +46,7 @@ function DatesTab({ intl }) {
<div role="heading" aria-level="1" className="h2 my-3">
{intl.formatMessage(messages.title)}
</div>
{ /** [MM-P2P] Experiment */ }
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
{isSelfPaced && hasDeadlines && (
<>
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
<SuggestedScheduleHeader />
@@ -60,10 +54,10 @@ function DatesTab({ intl }) {
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
</>
)}
<Timeline mmp2p={mmp2p} />
<Timeline />
</>
);
}
};
DatesTab.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Route } from 'react-router';
import { Routes, Route } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
@@ -32,11 +32,16 @@ describe('DatesTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
<Routes>
<Route
path="/course/:courseId/dates"
element={(
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
)}
/>
</Routes>
</UserMessagesProvider>
</AppProvider>
);
@@ -341,12 +346,12 @@ describe('DatesTab', () => {
it('redirects to the home page when unauthenticated', async () => {
await renderDenied('authentication_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
});
it('redirects to the home page when unenrolled', async () => {
await renderDenied('enrollment_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
});
});
});

View File

@@ -17,15 +17,13 @@ import { useModel } from '../../../generic/model-store';
import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from '../utils';
function Day({
const Day = ({
date,
first,
intl,
items,
last,
/** [MM-P2P] Example */
mmp2p,
}) {
}) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -37,11 +35,6 @@ function Day({
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
/** [MM-P2P] Experiment */
const mmp2pOverride = (
mmp2p.state.isEnabled
&& items.some((item) => item.dateType === 'verified-upgrade-deadline')
);
return (
<li className="dates-day pb-4" data-testid="dates-day">
{/* Top Line */}
@@ -57,8 +50,7 @@ function Day({
<div className="d-inline-block ml-3 pl-2">
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
<FormattedDate
/** [MM-P2P] Experiment */
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
value={date}
day="numeric"
month="short"
weekday="short"
@@ -68,10 +60,7 @@ function Day({
{badges}
</div>
{items.map((item) => {
/** [MM-P2P] Experiment (conditional) */
const { badges: itemBadges } = mmp2pOverride
? getBadgeListAndColor(new Date(mmp2p.state.upgradeDeadline), intl, item, items)
: getBadgeListAndColor(date, intl, item, items);
const { badges: itemBadges } = getBadgeListAndColor(date, intl, item, items);
const showDueDateTime = item.dateType === 'assignment-due-date';
const showLink = item.link && isLearnerAssignment(item);
@@ -107,22 +96,14 @@ function Day({
</OverlayTrigger>
)}
</div>
{ /** [MM-P2P] Experiment (conditional) */ }
{ mmp2pOverride
? (
<div className="small mb-2">
You are still eligible to upgrade to a Verified Certificate!
&nbsp; Unlock full course access and highlight the knowledge you&apos;ll gain.
</div>
)
: (item.description && <div className="small mb-2">{item.description}</div>)}
{item.description && <div className="small mb-2">{item.description}</div>}
</div>
);
})}
</div>
</li>
);
}
};
Day.propTypes = {
date: PropTypes.objectOf(Date).isRequired,
@@ -138,25 +119,11 @@ Day.propTypes = {
title: PropTypes.string,
})).isRequired,
last: PropTypes.bool,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
isEnabled: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
}),
}),
};
Day.defaultProps = {
first: false,
last: false,
/** [MM-P2P] Experiment */
mmp2p: {
state: {
isEnabled: false,
upgradeDeadline: '',
},
},
};
export default injectIntl(Day);

View File

@@ -1,6 +1,4 @@
import React from 'react';
/** [MM-P2P] Experiment (import) */
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useModel } from '../../../generic/model-store';
@@ -8,8 +6,7 @@ import { useModel } from '../../../generic/model-store';
import Day from './Day';
import { daycmp, isLearnerAssignment } from '../utils';
/** [MM-P2P] Experiment (argument) */
export default function Timeline({ mmp2p }) {
const Timeline = () => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -66,17 +63,10 @@ export default function Timeline({ mmp2p }) {
return (
<ul className="list-unstyled m-0 mt-4 pt-2">
{groupedDates.map((groupedDate) => (
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
<Day key={groupedDate.date} {...groupedDate} />
))}
</ul>
);
}
/** [MM-P2P] Experiment */
Timeline.propTypes = {
mmp2p: PropTypes.shape({}),
};
Timeline.defaultProps = {
mmp2p: {},
};
export default Timeline;

View File

@@ -0,0 +1,35 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useParams, generatePath, useNavigate } from 'react-router-dom';
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
const DiscussionTab = () => {
const { courseId } = useSelector(state => state.courseHome);
const { path } = useParams();
const [originalPath] = useState(path);
const navigate = useNavigate();
const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({
'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId });
navigate(`${basePath}/${payload.path}`);
},
});
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
return (
<iframe
src={discussionsUrl}
className="d-flex w-100 border-0"
height={iFrameHeight}
style={{ minHeight: '60rem' }}
title="discussion"
/>
);
};
DiscussionTab.propTypes = {};
export default injectIntl(DiscussionTab);

View File

@@ -0,0 +1,66 @@
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages';
import {
initializeMockApp, messageEvent, screen, waitFor,
} from '../../setupTest';
import initializeStore from '../../store';
import { TabContainer } from '../../tab-page';
import { appendBrowserTimezoneToUrl } from '../../utils';
import { fetchDiscussionTab } from '../data/thunks';
import DiscussionTab from './DiscussionTab';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('DiscussionTab', () => {
let axiosMock;
let store;
let component;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
<Route
path="/course/:courseId/discussion"
element={(
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
)}
/>
</Routes>
</UserMessagesProvider>
</AppProvider>
);
});
const courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
const { id: courseId } = courseMetadata;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
beforeEach(() => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
history.push(`/course/${courseId}/discussion`); // so tab can pull course id from url
render(component);
});
it('resizes when it gets a size hint from iframe', async () => {
window.postMessage({ ...messageEvent, payload: { height: 1234 } }, '*');
await waitFor(() => expect(screen.getByTitle('discussion'))
.toHaveAttribute('height', String(1234)));
});
});

View File

@@ -10,7 +10,7 @@ import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
function GoalUnsubscribe({ intl }) {
const GoalUnsubscribe = ({ intl }) => {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -33,6 +33,7 @@ function GoalUnsubscribe({ intl }) {
// as visiting this page is allowed to be done anonymously and without the context of the course.
// The token can be used to connect a user and course, it will just require some post-processing
sendTrackEvent('edx.ui.lms.goal.unsubscribe', { token });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // deps=[] to only run once
return (
@@ -48,7 +49,7 @@ function GoalUnsubscribe({ intl }) {
</main>
</>
);
}
};
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,7 +1,9 @@
import React from 'react';
import { Route } from 'react-router';
import {
MemoryRouter, Route, Routes,
} from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { getConfig, history } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react';
@@ -24,13 +26,16 @@ describe('GoalUnsubscribe', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<UserMessagesProvider>
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
<Routes>
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
</Routes>
</MemoryRouter>
</UserMessagesProvider>
</AppProvider>
);
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
});
it('starts with a spinner', () => {

View File

@@ -6,7 +6,7 @@ import { Button, Hyperlink } from '@edx/paragon';
import messages from './messages';
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
function ResultPage({ courseTitle, error, intl }) {
const ResultPage = ({ courseTitle, error, intl }) => {
const errorDescription = (
<FormattedMessage
id="learning.goals.unsubscribe.errorDescription"
@@ -44,7 +44,7 @@ function ResultPage({ courseTitle, error, intl }) {
</Button>
</>
);
}
};
ResultPage.defaultProps = {
courseTitle: null,

View File

@@ -0,0 +1,22 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
const LiveTab = () => {
const { courseId } = useSelector(state => state.courseHome);
const liveModel = useSelector(state => state.models.live);
useEffect(() => {
const iframe = document.getElementById('lti-tab-embed');
if (iframe) {
iframe.className += ' vh-100 w-100 border-0';
}
}, []);
return (
<div
id="live_tab"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: liveModel[courseId]?.iframe }}
/>
);
};
export default LiveTab;

View File

@@ -9,12 +9,10 @@ import { useModel } from '../../generic/model-store';
import { isLearnerAssignment } from '../dates-tab/utils';
import './DateSummary.scss';
export default function DateSummary({
const DateSummary = ({
dateBlock,
userTimezone,
/** [MM-P2P] Experiment */
mmp2p,
}) {
}) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -25,9 +23,6 @@ export default function DateSummary({
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
/** [MM-P2P] Experiment */
const showMMP2P = mmp2p.state.isEnabled && (dateBlock.dateType === 'verified-upgrade-deadline');
const logVerifiedUpgradeClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org,
@@ -45,8 +40,7 @@ export default function DateSummary({
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
<div className="ml-1 font-weight-bold">
<FormattedDate
/** [MM-P2P] Experiment */
value={showMMP2P ? mmp2p.state.upgradeDeadline : dateBlock.date}
value={dateBlock.date}
day="numeric"
month="short"
weekday="short"
@@ -55,48 +49,33 @@ export default function DateSummary({
/>
</div>
</div>
{/** [MM-P2P] Experiment (conditional) */}
{ showMMP2P ? (
<div className="row ml-4 pr-2">
<div className="date-summary-text">
<div className="row ml-4 pr-2">
<div className="date-summary-text">
{linkedTitle && (
<div className="font-weight-bold mt-2">
Last chance to upgrade
<a href={dateBlock.link}>{dateBlock.title}</a>
</div>
</div>
<div className="date-summary-text mt-1">
You are still eligible to upgrade to a Verified Certificate!
&nbsp; Unlock full course access and highlight the knowledge you&apos;ll gain.
</div>
</div>
) : (
<div className="row ml-4 pr-2">
<div className="date-summary-text">
{linkedTitle && (
<div className="font-weight-bold mt-2">
<a href={dateBlock.link}>{dateBlock.title}</a>
</div>
)}
{!linkedTitle && (
<div className="font-weight-bold mt-2">{dateBlock.title}</div>
)}
</div>
{dateBlock.description && (
<div className="date-summary-text mt-1">{dateBlock.description}</div>
)}
{!linkedTitle && dateBlock.link && (
<a
href={dateBlock.link}
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
className="description-link"
>
{dateBlock.linkText}
</a>
{!linkedTitle && (
<div className="font-weight-bold mt-2">{dateBlock.title}</div>
)}
</div>
)}
{dateBlock.description && (
<div className="date-summary-text mt-1">{dateBlock.description}</div>
)}
{!linkedTitle && dateBlock.link && (
<a
href={dateBlock.link}
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
className="description-link"
>
{dateBlock.linkText}
</a>
)}
</div>
</li>
);
}
};
DateSummary.propTypes = {
dateBlock: PropTypes.shape({
@@ -109,22 +88,10 @@ DateSummary.propTypes = {
learnerHasAccess: PropTypes.bool,
}).isRequired,
userTimezone: PropTypes.string,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
isEnabled: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
}),
}),
};
DateSummary.defaultProps = {
userTimezone: null,
/** [MM-P2P] Experiment */
mmp2p: {
state: {
isEnabled: false,
upgradeDeadline: '',
},
},
};
export default DateSummary;

View File

@@ -3,18 +3,18 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
export default function LmsHtmlFragment({
const LmsHtmlFragment = ({
className,
html,
title,
...rest
}) {
}) => {
const wholePage = `
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/static/LmsHtmlFragment.css">
</head>
<body class="${className}">${html}</body>
<script>
@@ -55,7 +55,7 @@ export default function LmsHtmlFragment({
{...rest}
/>
);
}
};
LmsHtmlFragment.defaultProps = {
className: '',
@@ -66,3 +66,5 @@ LmsHtmlFragment.propTypes = {
html: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};
export default LmsHtmlFragment;

View File

@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
@@ -28,10 +28,7 @@ import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
/** [MM-P2P] Experiment */
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
function OutlineTab({ intl }) {
const OutlineTab = ({ intl }) => {
const {
courseId,
proctoringPanelStatus,
@@ -69,6 +66,7 @@ function OutlineTab({ intl }) {
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
const eventProperties = {
org_key: org,
@@ -103,12 +101,28 @@ function OutlineTab({ intl }) {
return userRoleNames.includes('enterprise_learner');
};
/** [[MM-P2P] Experiment */
const MMP2P = initHomeMMP2P(courseId);
/** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
const location = useLocation();
useEffect(() => {
const currentParams = new URLSearchParams(location.search);
const startCourse = currentParams.get('start_course');
if (startCourse === '1') {
sendTrackEvent('enrollment.email.clicked.startcourse', {});
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('start_course');
navigate({
pathname: location.pathname,
search: `?${currentParams.toString()}`,
replace: true,
});
}
}, [location.search]);
return (
<>
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
@@ -116,7 +130,6 @@ function OutlineTab({ intl }) {
<div role="heading" aria-level="1" className="h2">{title}</div>
</div>
</div>
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
<div className="row course-outline-tab">
<AccountActivationAlert />
<div className="col-12">
@@ -128,21 +141,17 @@ function OutlineTab({ intl }) {
/>
</div>
<div className="col col-12 col-md-8">
{ /** [MM-P2P] Experiment (the conditional) */ }
{ !MMP2P.state.isEnabled
&& (
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...scheduledContentAlert,
}}
/>
)}
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...scheduledContentAlert,
}}
/>
{isSelfPaced && hasDeadlines && (
<>
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
@@ -185,35 +194,27 @@ function OutlineTab({ intl }) {
/>
)}
<CourseTools />
{ /** [MM-P2P] Experiment (conditional) */ }
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
: (
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
)}
<CourseDates
/** [MM-P2P] Experiment */
mmp2p={MMP2P}
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
<CourseDates />
<CourseHandouts />
</div>
)}
</div>
</>
);
}
};
OutlineTab.propTypes = {
intl: intlShape.isRequired,

View File

@@ -2,6 +2,7 @@
* @jest-environment jsdom
*/
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
@@ -51,9 +52,14 @@ describe('Outline Tab', () => {
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
}
async function fetchAndRender() {
async function fetchAndRender(path = '') {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<OutlineTab />, { store }));
await act(async () => render(
<MemoryRouter initialEntries={[path]}>
<OutlineTab />
</MemoryRouter>,
{ store },
));
}
beforeEach(async () => {
@@ -113,11 +119,11 @@ describe('Outline Tab', () => {
// Click to expand section
userEvent.click(expandButton);
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
// Click to collapse section
userEvent.click(expandButton);
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
});
it('displays correct icon for complete assignment', async () => {
@@ -138,25 +144,8 @@ describe('Outline Tab', () => {
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
});
it('SequenceLink displays points to legacy courseware', async () => {
it('SequenceLink displays link', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: false,
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const sequenceLink = screen.getByText('Title of Sequence');
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
});
it('SequenceLink displays points to courseware MFE', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: true,
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
@@ -355,6 +344,26 @@ describe('Outline Tab', () => {
expect(spy).toHaveBeenCalledTimes(0);
});
it('post goal via query param', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?weekly_goal=3');
expect(spy).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.setgoal', {});
});
it('emit start course event via query param', async () => {
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?start_course=1');
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.startcourse', {});
});
describe('weekly learning goal is not set', () => {
beforeEach(async () => {
setTabData({
@@ -374,25 +383,25 @@ describe('Outline Tab', () => {
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
});
it.each`
level | days
${'Casual'} | ${1}
${'Regular'} | ${3}
${'Intense'} | ${5}
`('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
});
it.each([
{ level: 'Casual', days: 1 },
{ level: 'Regular', days: 3 },
{ level: 'Intense', days: 5 },
])('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
});
it('shows and hides subscribe to reminders additional text', async () => {
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
fireEvent.click(button);
@@ -568,7 +577,7 @@ describe('Outline Tab', () => {
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
@@ -659,7 +668,6 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
cert_web_view_url: null,
certificate_available_date: tomorrow.toISOString(),
download_url: null,
},
}, {
date_blocks: [
@@ -687,7 +695,6 @@ describe('Outline Tab', () => {
cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
@@ -756,7 +763,6 @@ describe('Outline Tab', () => {
cert_data: {
cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
@@ -783,57 +789,16 @@ describe('Outline Tab', () => {
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
});
it('tracks download cert button', async () => {
sendTrackEvent.mockClear();
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
sendTrackEvent.mockClear();
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
);
});
it('tracks unverified cert button', async () => {
sendTrackEvent.mockClear();
const now = new Date();
@@ -844,7 +809,6 @@ describe('Outline Tab', () => {
cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
@@ -871,12 +835,14 @@ describe('Outline Tab', () => {
const requestingButton = screen.getByRole('link', { name: 'Verify my ID' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
},
);
});
});
@@ -932,7 +898,6 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: 'certificate/testuuid',
certificate_available_date: null,
download_url: null,
},
}, {
date_blocks: [
@@ -958,7 +923,6 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null,
certificate_available_date: null,
download_url: null,
},
}, {
date_blocks: [
@@ -975,33 +939,6 @@ describe('Outline Tab', () => {
});
});
describe('Certificate (pdf) Complete Alert', () => {
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: null,
certificate_available_date: null,
download_url: 'download/url',
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
});
describe('Proctoring Info Panel', () => {
const onboardingReleaseDate = new Date();
onboardingReleaseDate.setDate(new Date().getDate() - 7);
@@ -1084,6 +1021,22 @@ describe('Outline Tab', () => {
});
it('displays expiration warning', async () => {
const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('displays expiration warning for other course', async () => {
const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000);
@@ -1095,7 +1048,23 @@ describe('Outline Tab', () => {
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('displays expired', async () => {
const expirationDate = new Date();
// This message appears after expiration, set the date 10 days in the past
expirationDate.setTime(expirationDate.getTime() - 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});

View File

@@ -12,13 +12,13 @@ import { useModel } from '../../generic/model-store';
import genericMessages from '../../generic/messages';
import messages from './messages';
function Section({
const Section = ({
courseId,
defaultOpen,
expand,
intl,
section,
}) {
}) => {
const {
complete,
sequenceIds,
@@ -38,6 +38,7 @@ function Section({
useEffect(() => {
setOpen(defaultOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sectionTitle = (
@@ -109,7 +110,7 @@ function Section({
</Collapsible>
</li>
);
}
};
Section.propTypes = {
courseId: PropTypes.string.isRequired,

View File

@@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { Hyperlink } from '@edx/paragon';
import {
FormattedMessage,
FormattedTime,
@@ -17,38 +16,73 @@ import EffortEstimate from '../../shared/effort-estimate';
import { useModel } from '../../generic/model-store';
import messages from './messages';
function SequenceLink({
const SequenceLink = ({
id,
intl,
courseId,
first,
sequence,
}) {
}) => {
const {
complete,
description,
due,
legacyWebUrl,
showLink,
title,
} = sequence;
const {
userTimezone,
} = useModel('outline', courseId);
const {
canLoadCourseware,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
const coursewareUrl = (
canLoadCourseware
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
);
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
const displayTitle = showLink ? coursewareUrl : title;
const dueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-set"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
const noDueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-not-set"
defaultMessage="{description}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
@@ -80,35 +114,15 @@ function SequenceLink({
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
{due && (
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
<FormattedMessage
id="learning.outline.sequence-due"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
</small>
</div>
)}
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
{due ? dueDateMessage : noDueDateMessage}
</small>
</div>
</div>
</li>
);
}
};
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,

View File

@@ -25,7 +25,7 @@ export const CERT_STATUS_TYPE = {
UNVERIFIED: 'unverified',
};
function CertificateStatusAlert({ intl, payload }) {
const CertificateStatusAlert = ({ intl, payload }) => {
const dispatch = useDispatch();
const {
certificateAvailableDate,
@@ -33,7 +33,6 @@ function CertificateStatusAlert({ intl, payload }) {
courseEndDate,
courseId,
certURL,
isWebCert,
userTimezone,
org,
notPassingCourseEnded,
@@ -79,11 +78,7 @@ function CertificateStatusAlert({ intl, payload }) {
);
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
if (isWebCert) {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
} else {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
}
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
alertProps.buttonVisible = true;
alertProps.buttonLink = certURL;
alertProps.buttonAction = () => {
@@ -194,7 +189,7 @@ function CertificateStatusAlert({ intl, payload }) {
)}
</AlertWrapper>
);
}
};
CertificateStatusAlert.propTypes = {
intl: intlShape.isRequired,
@@ -204,7 +199,6 @@ CertificateStatusAlert.propTypes = {
courseEndDate: PropTypes.string,
courseId: PropTypes.string,
certURL: PropTypes.string,
isWebCert: PropTypes.bool,
userTimezone: PropTypes.string,
org: PropTypes.string,
notPassingCourseEnded: PropTypes.bool,

View File

@@ -51,10 +51,8 @@ function useCertificateStatusAlert(courseId) {
certStatus,
certWebViewUrl,
certificateAvailableDate,
downloadUrl,
} = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const isWebCert = downloadUrl === null;
const isVerifiedEnrollmentMode = (
enrollmentMode !== null
&& enrollmentMode !== undefined
@@ -63,9 +61,6 @@ function useCertificateStatusAlert(courseId) {
let certURL = '';
if (certWebViewUrl) {
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
} else if (downloadUrl) {
// PDF Certificate
certURL = downloadUrl;
}
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
@@ -80,22 +75,22 @@ function useCertificateStatusAlert(courseId) {
&& hasEnded
&& !userHasPassingGrade
);
const payload = {
const payload = useMemo(() => ({
certificateAvailableDate,
certURL,
certStatus,
courseId,
courseEndDate: endBlock && endBlock.date,
userTimezone,
isWebCert,
org,
notPassingCourseEnded,
tabs,
};
}), [certStatus, certURL, certificateAvailableDate, courseId,
endBlock, notPassingCourseEnded, org, tabs, userTimezone]);
useAlert(isVisible || notPassingCourseEnded, {
code: 'clientCertificateStatusAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'outline-course-alerts',
});

View File

@@ -3,15 +3,17 @@ import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelative,
FormattedRelativeTime,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
const DAY_SEC = 24 * 60 * 60; // in seconds
const DAY_MS = DAY_SEC * 1000; // in ms
const YEAR_SEC = 365 * DAY_SEC; // in seconds
function CourseEndAlert({ payload }) {
const CourseEndAlert = ({ payload }) => {
const {
description,
endDate,
@@ -20,16 +22,19 @@ function CourseEndAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
let msg;
const delta = new Date(endDate) - new Date();
const timeRemaining = (
<FormattedRelative
<FormattedRelativeTime
key="timeRemaining"
value={endDate}
value={delta / 1000}
numeric="auto"
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
updateIntervalInSeconds={YEAR_SEC}
{...timezoneFormatArgs}
/>
);
let msg;
const delta = new Date(endDate) - new Date();
if (delta < DAY_MS) {
const courseEndTime = (
<FormattedTime
@@ -83,7 +88,7 @@ function CourseEndAlert({ payload }) {
{description}
</Alert>
);
}
};
CourseEndAlert.propTypes = {
payload: PropTypes.shape({

View File

@@ -23,15 +23,15 @@ export function useCourseEndAlert(courseId) {
const endDate = endBlock ? new Date(endBlock.date) : null;
const delta = endBlock ? endDate - new Date() : 0;
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
const payload = {
const payload = useMemo(() => ({
description: endBlock && endBlock.description,
endDate: endBlock && endBlock.date,
userTimezone,
};
}), [endBlock, userTimezone]);
useAlert(isVisible, {
code: 'clientCourseEndAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'outline-course-alerts',
});

View File

@@ -14,7 +14,7 @@ import outlineMessages from '../../messages';
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
import { useModel } from '../../../../generic/model-store';
function PrivateCourseAlert({ intl, payload }) {
const PrivateCourseAlert = ({ intl, payload }) => {
const {
anonymousUser,
canEnroll,
@@ -100,7 +100,7 @@ function PrivateCourseAlert({ intl, payload }) {
)}
</Alert>
);
}
};
PrivateCourseAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -18,16 +18,16 @@ export function usePrivateCourseAlert(courseId) {
* 2. the user is authenticated.
* */
const isVisible = !enrolledUser && (privateOutline || authenticatedUser !== null);
const payload = {
const payload = useMemo(() => ({
anonymousUser: authenticatedUser === null,
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId,
};
}), [authenticatedUser, courseId, outline]);
useAlert(isVisible, {
code: 'clientPrivateCourseAlert',
dismissible: false,
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'outline-private-alerts',
type: ALERT_TYPES.WELCOME,
});

View File

@@ -3,7 +3,7 @@ import { Alert, Button } from '@edx/paragon';
import React from 'react';
import PropTypes from 'prop-types';
function ScheduledContentAlert({ payload }) {
const ScheduledContentAlert = ({ payload }) => {
const {
datesTabLink,
} = payload;
@@ -38,7 +38,7 @@ function ScheduledContentAlert({ payload }) {
</div>
</Alert>
);
}
};
ScheduledContentAlert.propTypes = {
payload: PropTypes.shape({

View File

@@ -20,12 +20,12 @@ const useScheduledContentAlert = (courseId) => {
&& !!Object.values(courses).find(course => course.hasScheduledContent === true)
);
const { isEnrolled } = useModel('courseHomeMeta', courseId);
const payload = {
const payload = useMemo(() => ({
datesTabLink,
};
}), [datesTabLink]);
useAlert(hasScheduledContent && isEnrolled, {
code: 'ScheduledContentAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'outline-course-alerts',
});

View File

@@ -231,6 +231,11 @@ const messages = defineMessages({
defaultMessage: 'Expiring Soon',
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
},
expiredProctoringStatus: {
id: 'learning.proctoringPanel.status.expired',
defaultMessage: 'Expired',
description: 'A label to indicate that proctortrack onboarding exam has expired',
},
proctoringCurrentStatus: {
id: 'learning.proctoringPanel.status',
defaultMessage: 'Current Onboarding Status:',
@@ -278,9 +283,14 @@ const messages = defineMessages({
},
expiringSoonProctoringMessage: {
id: 'learning.proctoringPanel.message.expiringSoon',
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
},
expiredProctoringMessage: {
id: 'learning.proctoringPanel.message.expired',
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
},
proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo',
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -8,11 +7,9 @@ import DateSummary from '../DateSummary';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseDates({
const CourseDates = ({
intl,
/** [MM-P2P] Experiment */
mmp2p,
}) {
}) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -40,8 +37,6 @@ function CourseDates({
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={userTimezone}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/>
))}
</ol>
@@ -51,17 +46,10 @@ function CourseDates({
</div>
</section>
);
}
};
CourseDates.propTypes = {
intl: intlShape.isRequired,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({}),
};
CourseDates.defaultProps = {
/** [MM-P2P] Experiment */
mmp2p: {},
};
export default injectIntl(CourseDates);

View File

@@ -7,7 +7,7 @@ import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseHandouts({ intl }) {
const CourseHandouts = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -29,7 +29,7 @@ function CourseHandouts({ intl }) {
/>
</section>
);
}
};
CourseHandouts.propTypes = {
intl: intlShape.isRequired,

View File

@@ -14,7 +14,7 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
function CourseTools({ intl }) {
const CourseTools = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -79,7 +79,7 @@ function CourseTools({ intl }) {
</ul>
</section>
);
}
};
CourseTools.propTypes = {
intl: intlShape.isRequired,

View File

@@ -2,35 +2,35 @@ import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
function FlagButton({
const FlagButton = ({
buttonIcon,
title,
text,
handleSelect,
isSelected,
}) {
return (
<button
type="button"
className={classnames('flag-button row w-100 align-content-between m-1.5 py-3.5',
isSelected ? 'flag-button-selected' : '')}
aria-checked={isSelected}
role="radio"
onClick={() => handleSelect()}
data-testid={`weekly-learning-goal-input-${title}`}
>
<div className="row w-100 m-0 justify-content-center pb-1">
{buttonIcon}
</div>
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
{title}
</div>
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
{text}
</div>
</button>
);
}
}) => (
<button
type="button"
className={classnames(
'flag-button row w-100 align-content-between m-1.5 py-3.5',
isSelected ? 'flag-button-selected' : '',
)}
aria-checked={isSelected}
role="radio"
onClick={() => handleSelect()}
data-testid={`weekly-learning-goal-input-${title}`}
>
<div className="row w-100 m-0 justify-content-center pb-1">
{buttonIcon}
</div>
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
{title}
</div>
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
{text}
</div>
</button>
);
FlagButton.propTypes = {
buttonIcon: PropTypes.element.isRequired,

View File

@@ -9,12 +9,12 @@ import { ReactComponent as FlagRegularIcon } from './flag_gray.svg';
import FlagButton from './FlagButton';
import messages from '../messages';
function LearningGoalButton({
const LearningGoalButton = ({
level,
isSelected,
handleSelect,
intl,
}) {
}) => {
const buttonDetails = {
casual: {
daysPerWeek: 1,
@@ -47,7 +47,7 @@ function LearningGoalButton({
isSelected={isSelected}
/>
);
}
};
LearningGoalButton.propTypes = {
level: PropTypes.string.isRequired,

View File

@@ -10,7 +10,7 @@ import { getProctoringInfoData } from '../../data/api';
import { fetchProctoringInfoResolved } from '../../data/slice';
import { useModel } from '../../../generic/model-store';
function ProctoringInfoPanel({ intl }) {
const ProctoringInfoPanel = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -35,6 +35,7 @@ function ProctoringInfoPanel({ intl }) {
error: 'error',
otherCourseApproved: 'otherCourseApproved',
expiringSoon: 'expiringSoon',
expired: 'expired',
};
function getReadableStatusClass(examStatus) {
@@ -54,9 +55,14 @@ function ProctoringInfoPanel({ intl }) {
return readableClass;
}
function isNotYetSubmitted(examStatus) {
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
return !NO_SHOW_STATES.includes(examStatus);
function isCurrentlySubmitted(examStatus) {
const SUBMITTED_STATES = ['submitted', 'second_review_required'];
return SUBMITTED_STATES.includes(examStatus);
}
function isSubmissionRequired(examStatus) {
const OK_STATES = [readableStatuses.submitted, readableStatuses.verified];
return !OK_STATES.includes(examStatus);
}
function isNotYetReleased(examReleaseDate) {
@@ -77,11 +83,19 @@ function ProctoringInfoPanel({ intl }) {
return borderClass;
}
function isExpiringSoon(dateString) {
// Returns true if the expiration date is within 28 days
function isExpired(dateString) {
// Returns true if the expiration date has passed
const today = new Date();
const expirationDateObject = new Date(dateString);
return today > expirationDateObject.getTime() - 2419200000;
return today >= expirationDateObject.getTime();
}
function isExpiringSoon(dateString) {
// Returns true if the expiration date is within 28 days
const twentyeightDays = 28 * 24 * 60 * 60 * 1000;
const today = new Date();
const expirationDateObject = new Date(dateString);
return today > expirationDateObject.getTime() - twentyeightDays;
}
useEffect(() => {
@@ -96,7 +110,9 @@ function ProctoringInfoPanel({ intl }) {
setStatus(response.onboarding_status);
setLink(response.onboarding_link);
const expirationDate = response.expiration_date;
if (expirationDate && isExpiringSoon(expirationDate)) {
if (expirationDate && isExpired(expirationDate)) {
setReadableStatus(getReadableStatusClass('expired'));
} else if (expirationDate && isExpiringSoon(expirationDate)) {
setReadableStatus(getReadableStatusClass('expiringSoon'));
} else {
setReadableStatus(getReadableStatusClass(response.onboarding_status));
@@ -112,6 +128,7 @@ function ProctoringInfoPanel({ intl }) {
.finally(() => {
dispatch(fetchProctoringInfoResolved());
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let onboardingExamButton = null;
@@ -154,6 +171,7 @@ function ProctoringInfoPanel({ intl }) {
}
return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{ showInfoPanel && (
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
@@ -175,17 +193,17 @@ function ProctoringInfoPanel({ intl }) {
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
<>
<p>
{isNotYetSubmitted(status) && (
{!isCurrentlySubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfo)
)}
{!isNotYetSubmitted(status) && (
{isCurrentlySubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
)}
</p>
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
</>
)}
{isNotYetSubmitted(status) && (
{isSubmissionRequired(readableStatus) && (
onboardingExamButton
)}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
@@ -196,7 +214,7 @@ function ProctoringInfoPanel({ intl }) {
)}
</>
);
}
};
ProctoringInfoPanel.propTypes = {
intl: intlShape.isRequired,

View File

@@ -7,7 +7,7 @@ import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function StartOrResumeCourseCard({ intl }) {
const StartOrResumeCourseCard = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -56,10 +56,11 @@ function StartOrResumeCourseCard({ intl }) {
)}
/>
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<Card.Footer><></></Card.Footer>
</Card>
);
}
};
StartOrResumeCourseCard.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,7 +1,9 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Form, Card, Icon } from '@edx/paragon';
import { history } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -13,11 +15,11 @@ import { saveWeeklyLearningGoal } from '../../data';
import { useModel } from '../../../generic/model-store';
import './FlagButton.scss';
function WeeklyLearningGoalCard({
const WeeklyLearningGoalCard = ({
daysPerWeek,
subscribedToReminders,
intl,
}) {
}) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -32,8 +34,9 @@ function WeeklyLearningGoalCard({
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
// eslint-disable-next-line react/prop-types
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
const location = useLocation();
function handleSelect(days) {
const handleSelect = (days, triggeredFromEmail = false) => {
// Set the subscription button if this is the first time selecting a goal
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
setGetReminderSelected(selectReminders);
@@ -47,8 +50,11 @@ function WeeklyLearningGoalCard({
num_days: days,
reminder_selected: selectReminders,
});
if (triggeredFromEmail) {
sendTrackEvent('enrollment.email.clicked.setgoal', {});
}
}
}
};
function handleSubscribeToReminders(event) {
const isGetReminderChecked = event.target.checked;
@@ -65,6 +71,22 @@ function WeeklyLearningGoalCard({
}
}
useEffect(() => {
const currentParams = new URLSearchParams(location.search);
const weeklyGoal = Number(currentParams.get('weekly_goal'));
if ([1, 3, 5].includes(weeklyGoal)) {
handleSelect(weeklyGoal, true);
// Deleting the weekly_goal query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('weekly_goal');
history.replace({
search: currentParams.toString(),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
return (
<Card
id="courseHome-weeklyLearningGoal"
@@ -125,7 +147,7 @@ function WeeklyLearningGoalCard({
)}
</Card>
);
}
};
WeeklyLearningGoalCard.propTypes = {
daysPerWeek: PropTypes.number,

Some files were not shown because too many files have changed in this diff Show More