Compare commits

..

80 Commits

Author SHA1 Message Date
Mashal Malik
a071eda78e refactor: Update README.rst 2023-10-27 11:00:34 +05:00
Mashal Malik
29fa6b3858 Update README.rst 2023-10-27 10:57:30 +05:00
mashal-m
119c0ff4fb refactor: updated README file to reflect template changes 2023-10-27 10:50:13 +05: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
123 changed files with 11495 additions and 9898 deletions

1
.env
View File

@@ -46,3 +46,4 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -15,7 +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'
EXAMS_BASE_URL=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -46,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

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

2
.gitignore vendored
View File

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

View File

@@ -1,6 +1,7 @@
export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
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,
@@ -17,19 +19,56 @@ Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
Development
-----------
***************
Getting Started
***************
Start Devstack
^^^^^^^^^^^^^^
Prerequisites
=============
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::
@@ -55,14 +94,14 @@ file (which is git-ignored) that defines where to find your local modules, for i
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,6 +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'
});

14092
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,46 +29,49 @@
"url": "https://github.com/openedx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-component-header": "^4.0.0",
"@edx/frontend-lib-special-exams": "^2.16.1",
"@edx/frontend-platform": "^4.2.0",
"@edx/paragon": "20.28.4",
"@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.16.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.6",
"@fortawesome/react-fontawesome": "^0.1.4",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"js-cookie": "3.0.1",
"js-cookie": "3.0.5",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"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.9",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-share": "4.4.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.7",
"reselect": "4.1.8",
"truncate-html": "1.0.4",
"util": "0.12.5"
},
"devDependencies": {
"@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "^12.8.27",
"@edx/reactifex": "2.1.1",
"@pact-foundation/pact": "9.17.3",
"@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",
"copy-webpack-plugin": "^11.0.0",

View File

@@ -68,7 +68,7 @@ const 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={{
@@ -88,7 +88,7 @@ const 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. "
/>

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,95 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import {
Close,
} from '@edx/paragon/icons';
import { setShowSearch } from '../data/slice';
import { useElementBoundingBox, useLockScroll } from './hooks';
import messages from './messages';
const CoursewareSearch = ({ intl, ...sectionProps }) => {
const dispatch = useDispatch();
useLockScroll();
const info = useElementBoundingBox('courseTabsNavigation');
const top = info ? `${Math.floor(info.top)}px` : 0;
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" style={{ height: '999px' }}>
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis semper rutrum odio quis congue.
Duis sodales nibh et sapien elementum fermentum. Quisque magna urna, gravida at gravida et,
ultricies vel massa.Aliquam in vehicula dolor, id scelerisque felis.
Morbi posuere scelerisque tincidunt. Proin et gravida tortor. Vestibulum vel orci vulputate,
gravida justo eu, varius dolor. Etiam viverra diam sed est tincidunt, et aliquam est efficitur.
Donec imperdiet eros quis est condimentum faucibus.
</p>
<p>
In mattis, tellus ut lacinia viverra, ligula ex sagittis ex, sed mollis ex enim ut velit.
Nunc elementum, risus eget feugiat scelerisque, sapien felis laoreet nisl, ut pharetra neque
lorem a elit. Maecenas elementum, metus fringilla suscipit imperdiet, mi nunc efficitur elit,
sed consequat massa magna sit amet dui. Curabitur ultrices nisi vel lorem scelerisque, pharetra
luctus nunc pulvinar. Morbi aliquam ante eget arcu condimentum consectetur. Fusce faucibus lacus
sed pretium ultrices. Curabitur neque lacus, elementum convallis augue placerat, gravida
scelerisque ipsum. Donec bibendum lectus id ullamcorper sodales. Integer quis ante facilisis erat
maximus viverra. Nunc rutrum posuere lectus, aliquam congue odio blandit nec. Phasellus placerat,
magna non bibendum lacinia, tortor orci vulputate dui, vitae imperdiet turpis dui nec tortor.
Praesent porttitor mollis diam ut gravida. Praesent vitae felis dignissim sem accumsan dignissim.
Fusce ullamcorper bibendum ante ac pellentesque. Aliquam sed leo vel leo pellentesque cursus a at risus.
Donec sollicitudin maximus diam, sit amet molestie sapien commodo at.
</p>
<p>
Cras ornare pulvinar est id rhoncus. Aenean ut risus magna. Fusce cursus pulvinar dui ut egestas.
Quisque condimentum risus non mi sagittis, eu facilisis enim hendrerit. Integer faucibus dapibus rutrum.
Nullam vitae mollis tortor, eu lacinia mi. Nunc commodo ex id eros hendrerit, vel interdum augue tristique.
Suspendisse ullamcorper, purus in vestibulum auctor, justo nisi finibus dolor,
nec dignissim arcu enim a augue.
</p>
<p>
Fusce vel libero odio. Orci varius natoque penatibus et magnis dis parturient montes,
nascetur ridiculus mus. Pellentesque at varius turpis. Ut pulvinar efficitur congue. Vivamus cursus,
purus at aliquet malesuada, felis quam blandit dolor, a interdum justo est semper augue.
In eu lectus sit amet est pellentesque porta vel eget magna. Morbi sollicitudin turpis vitae faucibus
pulvinar. Etiam placerat pulvinar porta.
</p>
<p>
Suspendisse mattis eget felis non sagittis. Nulla facilisi. In bibendum cursus purus, non venenatis tellus
dignissim sit amet. Phasellus volutpat ipsum turpis, non imperdiet nisi elementum a. Nunc mollis, sapien
cursus vehicula consectetur, nunc turpis pulvinar mauris, at varius justo mi egestas nisi. Fusce semper
sapien in orci rhoncus ornare. Donec maximus mi eu pulvinar convallis.
</p>
<p>
Nullam tortor sem, hendrerit eu sapien ac, venenatis rhoncus ligula. Donec nibh leo, venenatis sed interdum
ac, pharetra sed nibh. Orci varius natoque penatibus et magnis dis parturient montes,
nascetur ridiculus mus. Sed congue risus eu mattis condimentum. In id nulla sit amet magna suscipit
consectetur. Nullam vitae augue felis. In consequat tempus diam, a eleifend ante bibendum ac.
Vivamus mi orci, fermentum ac viverra quis, tristique a ipsum. Morbi imperdiet porta sem, in sollicitudin
risus dignissim at. Nulla dapibus iaculis vestibulum.
</p>
</div>
</div>
</section>
);
};
CoursewareSearch.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearch);

View File

@@ -0,0 +1,84 @@
import React from 'react';
import {
fireEvent,
initializeMockApp,
render,
screen,
} from '../../setupTest';
import { CoursewareSearch } from './index';
import { setShowSearch } from '../data/slice';
import { useElementBoundingBox, useLockScroll } from './hooks';
const mockDispatch = jest.fn();
jest.mock('./hooks');
jest.mock('../data/slice');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
const tabsTopPosition = 128;
function renderComponent(props = {}) {
const { container } = render(<CoursewareSearch {...props} />);
return container;
}
describe('CoursewareSearch', () => {
beforeAll(async () => {
initializeMockApp();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('when rendering normally', () => {
beforeAll(() => {
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
});
beforeEach(() => {
renderComponent();
});
it('Should use useElementBoundingBox() and useLockScroll() hooks', () => {
expect(useElementBoundingBox).toBeCalledTimes(1);
expect(useLockScroll).toBeCalledTimes(1);
});
it('Should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
const section = screen.getByTestId('courseware-search-section');
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
});
it('Should dispatch setShowSearch(true) when clicking the close button', () => {
const button = screen.getByTestId('courseware-search-close-button');
fireEvent.click(button);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setShowSearch).toHaveBeenCalledTimes(1);
expect(setShowSearch).toHaveBeenCalledWith(false);
});
});
describe('when CourseTabsNavigation is not present', () => {
it('Should use "--modal-top-position: 0" if nce element is not present', () => {
useElementBoundingBox.mockImplementation(() => undefined);
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', () => {
renderComponent({ foo: 'bar' });
const section = screen.getByTestId('courseware-search-section');
expect(section).toHaveAttribute('foo', 'bar');
});
});
});

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,45 @@
.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;
}
}
}
@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,21 @@
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.',
},
});
export default messages;

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,

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,6 +19,9 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
@@ -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,6 +341,9 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
@@ -520,6 +528,7 @@ Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -532,6 +541,9 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {

View File

@@ -204,12 +204,18 @@ export async function getDatesTabData(courseId) {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
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;
}
}
@@ -259,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 {};
@@ -269,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;
}
}
@@ -322,7 +334,20 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const requestTime = Date.now();
const tabData = await getAuthenticatedHttpClient().get(url);
let tabData;
try {
tabData = await getAuthenticatedHttpClient().get(url);
} catch (error) {
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 {
@@ -420,3 +445,9 @@ 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 };
}

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,15 +28,11 @@ 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 () => {
setTimeout(() => {

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

@@ -12,6 +12,7 @@ import {
postDismissWelcomeMessage,
postRequestCert,
getLiveTabIframe,
getCoursewareSearchEnabledFlag,
} from './api';
import {
@@ -139,3 +140,12 @@ export function processEvent(eventData, getTabData) {
}
};
}
export async function fetchCoursewareSearchSettings(courseId) {
try {
const { enabled } = await getCoursewareSearchEnabledFlag(courseId);
return { enabled };
} catch (e) {
return { enabled: false };
}
}

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

View File

@@ -2,21 +2,20 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { generatePath, useHistory } from 'react-router';
import { useParams } from 'react-router-dom';
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 history = useHistory();
const navigate = useNavigate();
const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({
'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId });
history.push(`${basePath}/${payload.path}`);
navigate(`${basePath}/${payload.path}`);
},
});
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;

View File

@@ -4,7 +4,7 @@ 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 } from 'react-router';
import { Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages';
import {
@@ -30,11 +30,16 @@ describe('DiscussionTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/discussion">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</Route>
<Routes>
<Route
path="/course/:courseId/discussion"
element={(
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
)}
/>
</Routes>
</UserMessagesProvider>
</AppProvider>
);

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

@@ -1,9 +1,8 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
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 { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
@@ -67,6 +66,7 @@ const OutlineTab = ({ intl }) => {
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
const eventProperties = {
org_key: org,
@@ -115,8 +115,10 @@ const OutlineTab = ({ intl }) => {
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('start_course');
history.replace({
search: currentParams.toString(),
navigate({
pathname: location.pathname,
search: `?${currentParams.toString()}`,
replace: true,
});
}
}, [location.search]);

View File

@@ -18,7 +18,7 @@ const ProgressTab = () => {
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked,
gradesFeatureIsFullyLocked, disableProgressGraph,
} = useModel('progress', courseId);
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
@@ -38,7 +38,7 @@ const ProgressTab = () => {
<div className="row w-100 m-0">
{/* Main body */}
<div className="col-12 col-md-8 p-0">
<CourseCompletion />
{!disableProgressGraph && <CourseCompletion />}
{!wideScreen && <CertificateStatus />}
<CourseGrade />
<div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>

View File

@@ -5,29 +5,41 @@ import classNames from 'classnames';
import messages from './messages';
import Tabs from '../generic/tabs/Tabs';
import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/courseware-search';
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
const CourseTabsNavigation = ({
activeTabSlug, className, tabs, intl,
}) => (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
</Tabs>
}) => {
const { show } = useCoursewareSearchState();
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="float-right">
<CoursewareSearchToggle />
</div>
<div className="container-xl">
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
</Tabs>
</div>
{show ? (
<CoursewareSearch data-testid="courseware-search" />
) : null}
</div>
</div>
);
);
};
CourseTabsNavigation.propTypes = {
activeTabSlug: PropTypes.string,

View File

@@ -1,14 +1,46 @@
import React from 'react';
import { initializeMockApp, render, screen } from '../setupTest';
import { AppProvider } from '@edx/frontend-platform/react';
import {
initializeMockApp, render, screen,
} from '../setupTest';
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
import { CourseTabsNavigation } from './index';
import initializeStore from '../store';
jest.mock('../course-home/courseware-search/hooks');
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
function renderComponent(props = { tabs: [] }) {
const store = initializeStore();
const { container } = render(
<AppProvider store={store}>
<CourseTabsNavigation {...props} />
</AppProvider>,
);
return container;
}
describe('Course Tabs Navigation', () => {
beforeAll(async () => {
initializeMockApp();
});
beforeEach(() => {
useCoursewareSearchState.mockImplementation(() => ({ show: false }));
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders without tabs', () => {
render(<CourseTabsNavigation tabs={[]} />);
renderComponent();
expect(screen.getByRole('button', { name: 'More...' })).toBeInTheDocument();
});
@@ -21,7 +53,7 @@ describe('Course Tabs Navigation', () => {
tabs,
activeTabSlug: tabs[0].slug,
};
render(<CourseTabsNavigation {...mockData} />);
renderComponent(mockData);
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveAttribute('href', tabs[0].url);
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveClass('active');
@@ -29,4 +61,17 @@ describe('Course Tabs Navigation', () => {
expect(screen.getByRole('link', { name: tabs[1].title })).toHaveAttribute('href', tabs[1].url);
expect(screen.getByRole('link', { name: tabs[1].title })).not.toHaveClass('active');
});
it('should NOT render CoursewareSearch if the flag is off', () => {
renderComponent();
expect(screen.queryByTestId('courseware-search')).not.toBeInTheDocument();
});
it('should render CoursewareSearch if the flag is on', () => {
useCoursewareSearchState.mockImplementation(() => ({ show: true }));
renderComponent();
expect(screen.queryByTestId('courseware-search')).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { createSelector } from '@reduxjs/toolkit';
import { defaultMemoize as memoize } from 'reselect';
@@ -17,45 +16,46 @@ import { TabPage } from '../tab-page';
import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
import withParamsAndNavigation from './utils';
// Look at where this is called in componentDidUpdate for more info about its usage
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => {
if (courseStatus === 'loaded' && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk.
getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history.
if (data.sectionId && data.unitId) {
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true });
} else if (firstSequenceId) {
history.replace(`/course/${courseId}/${firstSequenceId}`);
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true });
}
});
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
history.replace(`/course/${courseId}/${unitId}`);
navigate(`/course/${courseId}/${unitId}`, { replace: true });
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
// If the section is non-empty, redirect to its first sequence.
if (section.sequenceIds && section.sequenceIds[0]) {
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true });
// Otherwise, just go to the course root, letting the resume redirect take care of things.
} else {
history.replace(`/course/${courseId}`);
navigate(`/course/${courseId}`, { replace: true });
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkUnitToSequenceUnitRedirect = memoize(
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
if (sequenceMightBeUnit) {
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then
@@ -64,60 +64,62 @@ const checkUnitToSequenceUnitRedirect = memoize(
getSequenceForUnitDeprecated(courseId, unitId).then(
parentId => {
if (parentId) {
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true });
} else {
history.replace(`/course/${courseId}`);
navigate(`/course/${courseId}`, { replace: true });
}
},
() => { // error case
history.replace(`/course/${courseId}`);
navigate(`/course/${courseId}`, { replace: true });
},
);
} else {
// Invalid sequence that isn't a unit either. Redirect up to main course.
history.replace(`/course/${courseId}`);
navigate(`/course/${courseId}`, { replace: true });
}
}
},
);
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => {
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history.
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true });
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
const hasUnits = sequence.unitIds?.length > 0;
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
history.replace(`/course/${courseId}/${sequence.id}/${firstUnitId}`);
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize(
(courseId, sequenceStatus, sequence, unitId, navigate) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
} else {
const hasUnits = sequence.unitIds?.length > 0;
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
navigate(`/course/${courseId}/${sequence.id}/${firstUnitId}`, { replace: true });
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
navigate(`/course/${courseId}/${sequence.id}/${lastUnitId}`, { replace: true });
} else {
// No units... go to general sequence page
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
}
}
});
},
);
class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((unitId) => {
@@ -145,12 +147,8 @@ class CoursewareContainer extends Component {
componentDidMount() {
const {
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
},
},
routeCourseId,
routeSequenceId,
} = this.props;
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
@@ -167,13 +165,10 @@ class CoursewareContainer extends Component {
sequence,
firstSequenceId,
sectionViaSequenceId,
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
unitId: routeUnitId,
},
},
routeCourseId,
routeSequenceId,
routeUnitId,
navigate,
} = this.props;
// Load data whenever the course or sequence ID changes.
@@ -202,7 +197,7 @@ class CoursewareContainer extends Component {
// Check resume redirect:
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId
// based on sequence/unit where user was last active.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate);
// Check section-unit to unit redirect:
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
@@ -215,60 +210,54 @@ class CoursewareContainer extends Component {
// otherwise, we could get stuck in a redirect loop, since a sequence that failed to load
// would endlessly redirect to itself through `checkSectionUnitToUnitRedirect`
// and `checkUnitToSequenceUnitRedirect`.
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
// Check section to sequence redirect:
// /course/:courseId/:sectionId -> /course/:courseId/:sequenceId
// by redirecting to the first sequence within the section.
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
// Check unit to sequence-unit redirect:
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect((
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit,
sequenceId, sectionViaSequenceId, routeUnitId, navigate
));
// Check sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the most-recently-active unit in the sequence, OR
// the ID of the first unit the sequence if none is active.
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
// Check sequence-unit marker to sequence-unit redirect:
// /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId
// /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the first or last unit in the sequence.
// "Sequence unit marker" is an invented term used only in this component.
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
}
handleUnitNavigationClick = (nextUnitId) => {
handleUnitNavigationClick = () => {
const {
courseId, sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
courseId,
sequenceId,
routeUnitId,
} = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
};
handleNextSequenceClick = () => {
const {
course,
courseId,
nextSequence,
sequence,
sequenceId,
} = this.props;
if (nextSequence !== null) {
history.push(`/course/${courseId}/${nextSequence.id}/first`);
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
handleNextSectionCelebration(sequenceId, nextSequence.id);
@@ -276,23 +265,14 @@ class CoursewareContainer extends Component {
}
};
handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
history.push(`/course/${courseId}/${previousSequence.id}/last`);
}
};
handlePreviousSequenceClick = () => {};
render() {
const {
courseStatus,
courseId,
sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
routeUnitId,
} = this.props;
return (
@@ -335,13 +315,9 @@ const courseShape = PropTypes.shape({
});
CoursewareContainer.propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
routeCourseId: PropTypes.string.isRequired,
routeSequenceId: PropTypes.string,
routeUnitId: PropTypes.string,
courseId: PropTypes.string,
sequenceId: PropTypes.string,
firstSequenceId: PropTypes.string,
@@ -357,11 +333,14 @@ CoursewareContainer.propTypes = {
checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
};
CoursewareContainer.defaultProps = {
courseId: null,
sequenceId: null,
routeSequenceId: null,
routeUnitId: null,
firstSequenceId: null,
nextSequence: null,
previousSequence: null,
@@ -476,4 +455,4 @@ export default connect(mapStateToProps, {
saveSequencePosition,
fetchCourse,
fetchSequence,
})(CoursewareContainer);
})(withParamsAndNavigation(CoursewareContainer));

View File

@@ -5,13 +5,16 @@ import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Route, Switch } from 'react-router';
import {
BrowserRouter, MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import { initializeMockApp, waitFor } from '../setupTest';
import { DECODE_ROUTES } from '../constants';
import CoursewareContainer from './CoursewareContainer';
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
@@ -80,18 +83,16 @@ describe('CoursewareContainer', () => {
store = initializeStore();
component = (
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<UserMessagesProvider>
<Switch>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
</Switch>
<Routes>
{DECODE_ROUTES.COURSEWARE.map((route) => (
<Route
path={route}
element={<CoursewareContainer />}
/>
))}
</Routes>
</UserMessagesProvider>
</AppProvider>
);
@@ -151,7 +152,7 @@ describe('CoursewareContainer', () => {
}
async function loadContainer() {
const { container } = render(component);
const { container } = render(<BrowserRouter>{component}</BrowserRouter>);
// Wait for the page spinner to be removed, such that we can wait for our main
// content to load before making any assertions.
await waitForElementToBeRemoved(screen.getByRole('status'));
@@ -160,7 +161,7 @@ describe('CoursewareContainer', () => {
it('should initialize to show a spinner', () => {
history.push('/course/abc123');
render(component);
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>);
const spinner = screen.getByRole('status');
@@ -185,7 +186,7 @@ describe('CoursewareContainer', () => {
function assertSequenceNavigation(container, expectedUnitCount = 3) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
@@ -413,10 +414,10 @@ describe('CoursewareContainer', () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await waitFor(() => loadContainer());
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];
expect(sequenceNextButton).toHaveTextContent('Next');
fireEvent.click(sequenceNavButtons[4]);
fireEvent.click(sequenceNextButton);
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
});

View File

@@ -1,56 +1,44 @@
import React from 'react';
import { Switch, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { Routes, Route } from 'react-router-dom';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageRoute } from '@edx/frontend-platform/react';
import { PageWrap } from '@edx/frontend-platform/react';
import queryString from 'query-string';
import PageLoading from '../generic/PageLoading';
import DecodePageRoute from '../decode-page-route';
import { DECODE_ROUTES, REDIRECT_MODES, ROUTES } from '../constants';
import RedirectPage from './RedirectPage';
const CoursewareRedirectLandingPage = () => {
const { path } = useRouteMatch();
return (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
)}
const CoursewareRedirectLandingPage = () => (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
)}
/>
<Switch>
<DecodePageRoute
path={`${path}/survey/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
}}
/>
<PageRoute
path={`${path}/dashboard`}
render={({ location }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
}}
/>
<PageRoute
path={`${path}/consent/`}
render={({ location }) => {
const { consentPath } = queryString.parse(location.search);
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
}}
/>
<DecodePageRoute
path={`${path}/home/:courseId`}
render={({ match }) => {
global.location.assign(`/course/${match.params.courseId}/home`);
}}
/>
</Switch>
</div>
);
};
<Routes>
<Route
path={DECODE_ROUTES.REDIRECT_SURVEY}
element={<DecodePageRoute><RedirectPage pattern="/courses/:courseId/survey" mode={REDIRECT_MODES.SURVEY_REDIRECT} /></DecodePageRoute>}
/>
<Route
path={ROUTES.DASHBOARD}
element={<PageWrap><RedirectPage pattern="/dashboard" mode={REDIRECT_MODES.DASHBOARD_REDIRECT} /></PageWrap>}
/>
<Route
path={ROUTES.CONSENT}
element={<PageWrap><RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} /></PageWrap>}
/>
<Route
path={DECODE_ROUTES.REDIRECT_HOME}
element={<DecodePageRoute><RedirectPage pattern="/course/:courseId/home" mode={REDIRECT_MODES.HOME_REDIRECT} /></DecodePageRoute>}
/>
</Routes>
</div>
);
export default CoursewareRedirectLandingPage;

View File

@@ -1,19 +1,12 @@
import React from 'react';
import { Router } from 'react-router';
import { createMemoryHistory } from 'history';
import { MemoryRouter as Router } from 'react-router-dom';
import { render, initializeMockApp } from '../setupTest';
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
const redirectUrl = jest.fn();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: '/redirect',
}),
}));
jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children}</div>));
describe('CoursewareRedirectLandingPage', () => {
beforeEach(async () => {
@@ -23,12 +16,8 @@ describe('CoursewareRedirectLandingPage', () => {
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
});
render(
<Router history={history}>
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}>
<CoursewareRedirectLandingPage />
</Router>,
);
@@ -37,12 +26,8 @@ describe('CoursewareRedirectLandingPage', () => {
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
});
render(
<Router history={history}>
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}>
<CoursewareRedirectLandingPage />
</Router>,
);

View File

@@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import {
generatePath, useParams, useLocation,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import queryString from 'query-string';
import { REDIRECT_MODES } from '../constants';
const RedirectPage = ({
pattern, mode,
}) => {
const { courseId } = useParams();
const location = useLocation();
const { consentPath } = queryString.parse(location?.search);
const BASE_URL = getConfig().LMS_BASE_URL;
switch (mode) {
case REDIRECT_MODES.DASHBOARD_REDIRECT:
global.location.assign(`${BASE_URL}${pattern}${location?.search}`);
break;
case REDIRECT_MODES.CONSENT_REDIRECT:
global.location.assign(`${BASE_URL}${consentPath}`);
break;
case REDIRECT_MODES.HOME_REDIRECT:
global.location.assign(generatePath(pattern, { courseId }));
break;
default:
global.location.assign(`${BASE_URL}${generatePath(pattern, { courseId })}`);
}
return null;
};
RedirectPage.propTypes = {
pattern: PropTypes.string,
mode: PropTypes.string.isRequired,
};
RedirectPage.defaultProps = {
pattern: null,
};
export default RedirectPage;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import RedirectPage from './RedirectPage';
import { REDIRECT_MODES } from '../constants';
const BASE_URL = getConfig().LMS_BASE_URL;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
courseId: 'course-id-123',
}),
useLocation: () => ({
search: '?consentPath=/some-path',
}),
}));
describe('RedirectPage component', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { assign: jest.fn() },
});
jest.clearAllMocks();
});
it('should handle DASHBOARD_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.DASHBOARD_REDIRECT} pattern="/dashboard" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/dashboard?consentPath=/some-path`);
});
it('should handle CONSENT_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/some-path`);
});
it('should handle HOME_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.HOME_REDIRECT} pattern="/course/:courseId/home" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith('/course/course-id-123/home');
});
it('should handle the default case correctly', () => {
render(
<MemoryRouter>
<RedirectPage pattern="/default/:courseId" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/default/course-id-123`);
});
});

View File

@@ -10,6 +10,7 @@ import { AlertList } from '../../generic/user-messages';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
import Chat from './chat/Chat';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarProvider from './sidebar/SidebarContextProvider';
@@ -91,7 +92,16 @@ const Course = ({
unitId={unitId}
/>
{shouldDisplayTriggers && (
<SidebarTriggers />
<>
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
courseId={courseId}
contentToolsEnabled={course.showCalculator || course.notes.enabled}
/>
<SidebarTriggers />
</>
)}
</div>

View File

@@ -15,6 +15,10 @@ import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
const recordFirstSectionCelebration = jest.fn();
// eslint-disable-next-line no-import-assign
@@ -49,8 +53,7 @@ describe('Course', () => {
setItemSpy.mockRestore();
});
const setupDiscussionSidebar = async (storageValue = false) => {
localStorage.clear();
const setupDiscussionSidebar = async () => {
const testStore = await initializeTestStore({ provider: 'openedx' });
const state = testStore.getState();
const { courseware: { courseId } } = state;
@@ -65,14 +68,12 @@ describe('Course', () => {
mockData.unitId = firstUnitId;
const [firstSequenceId] = Object.keys(state.models.sequences);
mockData.sequenceId = firstSequenceId;
if (storageValue !== null) {
localStorage.setItem('showDiscussionSidebar', storageValue);
}
await render(<Course {...mockData} />, { store: testStore });
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
};
it('loads learning sequence', async () => {
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
@@ -105,7 +106,7 @@ describe('Course', () => {
};
// Set up LocalStorage for testing.
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
@@ -123,7 +124,7 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
@@ -131,31 +132,71 @@ describe('Course', () => {
});
it('displays notification trigger and toggles active class on click', async () => {
localStorage.setItem('showDiscussionSidebar', false);
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
expect(notificationTrigger.parentNode).not.toHaveClass('mt-3', { exact: true });
});
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
expect(discussionsSideBar).not.toHaveClass('d-none');
await act(async () => {
fireEvent.click(discussionsTrigger);
});
await expect(discussionsSideBar).toHaveClass('d-none');
await act(async () => {
fireEvent.click(discussionsTrigger);
});
await expect(discussionsSideBar).not.toHaveClass('d-none');
});
it('displays discussions sidebar when unit changes', async () => {
const testStore = await initializeTestStore();
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
await setupDiscussionSidebar();
const { rerender } = render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
});
rerender(null);
});
it('handles click to open/close notification tray', async () => {
sessionStorage.clear();
localStorage.setItem('showDiscussionSidebar', false);
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
});
it('handles reload persisting notification tray status', async () => {
sessionStorage.clear();
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
@@ -174,13 +215,12 @@ describe('Course', () => {
it('handles sessionStorage from a different course for the notification tray', async () => {
sessionStorage.clear();
localStorage.setItem('showDiscussionSidebar', false);
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
// set sessionStorage for a different course before rendering Course
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
render(<Course {...mockData} />);
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
@@ -208,7 +248,7 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -217,34 +257,6 @@ describe('Course', () => {
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
});
[
{ value: true, visible: true },
{ value: false, visible: false },
{ value: null, visible: true },
].forEach(async ({ value, visible }) => (
it(`discussion sidebar is ${visible ? 'shown' : 'hidden'} when localstorage value is ${value}`, async () => {
await setupDiscussionSidebar(value);
const element = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
if (visible) {
expect(element).not.toHaveClass('d-none');
} else {
expect(element).toHaveClass('d-none');
}
})));
[
{ value: true, result: 'false' },
{ value: false, result: 'true' },
].forEach(async ({ value, result }) => (
it(`Discussion sidebar storage value is ${!value} when sidebar is ${value ? 'closed' : 'open'}`, async () => {
await setupDiscussionSidebar(value);
await act(async () => {
const button = await screen.queryByRole('button', { name: /Show discussions tray/i });
button.click();
});
expect(localStorage.getItem('showDiscussionSidebar')).toBe(result);
})));
it('passes handlers to the sequence', async () => {
const nextSequenceHandler = jest.fn();
const previousSequenceHandler = jest.fn();
@@ -268,12 +280,12 @@ describe('Course', () => {
previousSequenceHandler,
unitNavigationHandler,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
@@ -297,7 +309,7 @@ describe('Course', () => {
courseId: courseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
});
@@ -331,7 +343,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
});
@@ -365,7 +377,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore });
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
});
});

View File

@@ -1,57 +1,80 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { SelectMenu } from '@edx/paragon';
import { useToggle, ModalPopup, Menu } from '@edx/paragon';
import { Link } from 'react-router-dom';
import { useModel, useModels } from '../../generic/model-store';
import JumpNavMenuItem from './JumpNavMenuItem';
const CourseBreadcrumb = ({
content, withSeparator, courseId, sequenceId, unitId, isStaff,
content,
withSeparator,
courseId,
sequenceId,
unitId,
isStaff,
}) => {
const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '', sequences: [] };
const defaultContent = content.filter(
(destination) => destination.default,
)[0] || { id: courseId, label: '', sequences: [] };
const showRegularLink = getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff;
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
return (
<>
{withSeparator && (
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
)}
<li style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
<li
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-testid="breadcrumb-item"
>
{ getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
? (
<Link
className="text-primary-500"
to={defaultContent.sequences.length
{showRegularLink ? (
<Link
className="text-primary-500"
to={
defaultContent.sequences.length
? `/course/${courseId}/${defaultContent.sequences[0].id}`
: `/course/${courseId}/${defaultContent.id}`}
>
{defaultContent.label}
</Link>
)
: (
<SelectMenu isLink defaultMessage={defaultContent.label}>
{content.map(item => (
<JumpNavMenuItem
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
/>
))}
</SelectMenu>
)}
: `/course/${courseId}/${defaultContent.id}`
}
>
{defaultContent.label}
</Link>
) : (
<>
{
// eslint-disable-next-line
<a className="text-primary-500" onClick={open} ref={setTarget}>
{defaultContent.label}
</a>
}
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
<Menu>
{content.map((item) => (
<JumpNavMenuItem
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
onClick={close}
/>
))}
</Menu>
</ModalPopup>
</>
)}
</li>
</>
);
@@ -87,14 +110,21 @@ const CourseBreadcrumbs = ({
isStaff,
}) => {
const course = useModel('coursewareMeta', courseId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const courseStatus = useSelector((state) => state.courseware.courseStatus);
const sequenceStatus = useSelector(
(state) => state.courseware.sequenceStatus,
);
const allSequencesInSections = Object.fromEntries(useModels('sections', course.sectionIds).map(section => [section.id, {
default: section.id === sectionId,
title: section.title,
sequences: useModels('sequences', section.sequenceIds),
}]));
const allSequencesInSections = Object.fromEntries(
useModels('sections', course.sectionIds).map((section) => [
section.id,
{
default: section.id === sectionId,
title: section.title,
sequences: useModels('sequences', section.sequenceIds),
},
]),
);
const links = useMemo(() => {
const chapters = [];
@@ -108,7 +138,7 @@ const CourseBreadcrumbs = ({
sequences: section.sequences,
});
if (section.default) {
section.sequences.forEach(sequence => {
section.sequences.forEach((sequence) => {
sequentials.push({
id: sequence.id,
label: sequence.title,
@@ -124,11 +154,12 @@ const CourseBreadcrumbs = ({
return (
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<li className="list-unstyled col-auto m-0 p-0">
<Link
className="flex-shrink-0 text-primary"
to={`/course/${courseId}/home`}
replace
>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage
@@ -138,7 +169,7 @@ const CourseBreadcrumbs = ({
/>
</Link>
</li>
{links.map(content => (
{links.map((content) => (
<CourseBreadcrumb
courseId={courseId}
sequenceId={sequenceId}

View File

@@ -26,6 +26,12 @@ jest.mock('react-redux', () => ({
Provider: ({ children }) => children,
useSelector: () => 'loaded',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Link: jest.fn().mockImplementation(({ to, children }) => (
<a href={to}>{children}</a>
)),
}));
useModels.mockImplementation((name) => {
if (name === 'sections') {
@@ -123,6 +129,6 @@ describe('CourseBreadcrumbs', () => {
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByRole('button')).toHaveLength(2);
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
});
});

View File

@@ -1,12 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { history } from '@edx/frontend-platform';
import { MenuItem } from '@edx/paragon';
import { Dropdown } from '@edx/paragon';
import {
sendTrackingLogEvent,
sendTrackEvent,
} from '@edx/frontend-platform/analytics';
import { useNavigate } from 'react-router-dom';
const JumpNavMenuItem = ({
title,
@@ -15,7 +15,10 @@ const JumpNavMenuItem = ({
currentUnit,
sequences,
isDefault,
onClick,
}) => {
const navigate = useNavigate();
function logEvent(targetUrl) {
const eventName = 'edx.ui.lms.jump_nav.selected';
const payload = {
@@ -34,19 +37,20 @@ const JumpNavMenuItem = ({
}
return `/course/${courseId}/${sequences[0].id}`;
}
function handleClick() {
function handleClick(e) {
const url = destinationUrl();
logEvent(url);
history.push(url);
navigate(url);
if (onClick) { onClick(e); }
}
return (
<MenuItem
defaultSelected={isDefault}
onClick={() => handleClick()}
<Dropdown.Item
active={isDefault}
onClick={e => handleClick(e)}
>
{title}
</MenuItem>
</Dropdown.Item>
);
};
@@ -54,6 +58,10 @@ const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired,
});
JumpNavMenuItem.defaultProps = {
onClick: null,
};
JumpNavMenuItem.propTypes = {
title: PropTypes.string.isRequired,
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
@@ -61,6 +69,7 @@ JumpNavMenuItem.propTypes = {
courseId: PropTypes.string.isRequired,
currentSequence: PropTypes.string.isRequired,
currentUnit: PropTypes.string.isRequired,
onClick: PropTypes.func,
};
export default JumpNavMenuItem;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { screen, render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import JumpNavMenuItem from './JumpNavMenuItem';
import { fireEvent } from '../../setupTest';
@@ -22,12 +23,15 @@ const mockData = {
},
],
isDefault: false,
onClick: jest.fn().mockName('onClick'),
};
describe('JumpNavMenuItem', () => {
render(
<JumpNavMenuItem
{...mockData}
/>,
<BrowserRouter>
<JumpNavMenuItem
{...mockData}
/>
</BrowserRouter>,
);
it('renders menu Item as expected with button and Text and handles clicks', () => {
expect(screen.queryAllByRole('button')).toHaveLength(1);

View File

@@ -0,0 +1,76 @@
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
const Chat = ({
enabled,
enrollmentMode,
isStaff,
courseId,
contentToolsEnabled,
}) => {
const VERIFIED_MODES = [
'professional',
'verified',
'no-id-professional',
'credit',
'masters',
'executive-education',
'paid-executive-education',
'paid-bootcamp',
];
const AUDIT_MODES = [
'audit',
'honor',
'unpaid-executive-education',
'unpaid-bootcamp',
];
const isEnrolled = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& [...VERIFIED_MODES, ...AUDIT_MODES].some(mode => mode === enrollmentMode)
);
const shouldDisplayChat = (
enabled
&& (isEnrolled || isStaff) // display only to enrolled or staff
);
// TODO: Remove this Segment alert. This has been added purely to diagnose whether
// usage issues are as a result of the Xpert toggle button not appearing.
if (shouldDisplayChat) {
sendTrackEvent('edx.ui.lms.learning_assistant.render', {
course_id: courseId,
});
}
return (
<>
{/* Use a portal to ensure that component overlay does not compete with learning MFE styles. */}
{shouldDisplayChat && (createPortal(
<Xpert courseId={courseId} contentToolsEnabled={contentToolsEnabled} />,
document.body,
))}
</>
);
};
Chat.propTypes = {
isStaff: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
contentToolsEnabled: PropTypes.bool.isRequired,
};
Chat.defaultProps = {
enrollmentMode: null,
};
export default injectIntl(Chat);

View File

@@ -0,0 +1,157 @@
import { BrowserRouter } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import React from 'react';
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
import { initializeMockApp, render, screen } from '../../../setupTest';
import Chat from './Chat';
jest.mock('@edx/frontend-platform/analytics');
initializeMockApp();
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let testCases = [];
let enabledTestCases = [];
let disabledTestCases = [];
const enabledModes = [
'professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education',
'paid-executive-education', 'paid-bootcamp', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp',
];
const disabledModes = [null, undefined, 'xyz'];
describe('Chat', () => {
// Generate test cases.
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
testCases = enabledTestCases.concat(disabledTestCases);
testCases.forEach(test => {
it(
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
// Generate test cases.
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
testCases.forEach(test => {
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
});
});
// Generate the map function used for generating test cases by currying the map function.
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
// defining two separate map functions that differ in only one case, curry the function.
const generateMapFunction = (areEnabledModes) => (
(mode) => (
[
{
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
},
{
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
},
{
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
},
{
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
},
]
)
);
// Generate test cases.
enabledTestCases = enabledModes.map(generateMapFunction(true));
disabledTestCases = disabledModes.map(generateMapFunction(false));
testCases = enabledTestCases.concat(disabledTestCases);
testCases = testCases.flat();
testCases.forEach(test => {
it(
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
and ${test.enrollmentMode} enrollment mode`,
async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={test.isStaff}
enabled={test.enabled}
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
});

View File

@@ -0,0 +1 @@
export { default } from './Chat';

View File

@@ -1,23 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import Calculator from './calculator';
import NotesVisibility from './notes-visibility';
const ContentTools = ({
course,
}) => (
<div className="content-tools">
<div className="d-flex justify-content-end align-items-end m-0">
{course.showCalculator && (
<Calculator />
)}
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
);
}) => {
const {
sidebarIsOpen,
} = useSelector(state => state.learningAssistant);
return (
!sidebarIsOpen && (
<div className="content-tools">
<div className="d-flex justify-content-end align-items-end m-0">
{course.showCalculator && (
<Calculator />
)}
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
)
);
};
ContentTools.propTypes = {
course: PropTypes.shape({

View File

@@ -25,7 +25,7 @@ class NotesVisibility extends Component {
}
handleClick = () => {
const data = { visibility: this.state.visible };
const data = { visibility: !this.state.visible };
getAuthenticatedHttpClient().put(
this.visibilityUrl,
data,

View File

@@ -85,7 +85,7 @@ describe('Notes Visibility', () => {
expect(axiosMock.history.put).toHaveLength(1);
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${mockData.course.notes.visible}}`);
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${!mockData.course.notes.visible}}`);
expect(screen.getByRole('switch', { name: 'Hide Notes' })).toBeInTheDocument();
});

View File

@@ -4,7 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { useSelector } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import CourseCelebration from './CourseCelebration';
import CourseInProgress from './CourseInProgress';
@@ -58,7 +58,7 @@ const CourseExit = ({ intl }) => {
} else if (mode === COURSE_EXIT_MODES.celebration) {
body = (<CourseCelebration />);
} else {
return (<Redirect to={`/course/${courseId}`} />);
return (<Navigate to={`/course/${courseId}`} replace />);
}
return (

View File

@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
async function fetchAndRender(component) {
await executeThunk(fetchCourse(courseId), store.dispatch);
render(component, { store });
render(component, { store, wrapWithRouter: true });
}
beforeEach(() => {

View File

@@ -10,7 +10,6 @@ import {
} from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { breakpoints, useWindowSize } from '@edx/paragon';
@@ -139,9 +138,6 @@ const Sequence = ({
}
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const goToCourseExitPage = () => {
history.push(`/course/${courseId}/course-end`);
};
const defaultContent = (
<div className="sequence-container d-inline-flex flex-row">
@@ -150,7 +146,7 @@ const Sequence = ({
sequenceId={sequenceId}
unitId={unitId}
className="mb-4"
nextSequenceHandler={() => {
nextHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
@@ -158,11 +154,10 @@ const Sequence = ({
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousSequenceHandler={() => {
previousHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
@@ -186,7 +181,6 @@ const Sequence = ({
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
)}
</div>

View File

@@ -11,6 +11,10 @@ import Sequence from './Sequence';
import { fetchSequenceFailure } from '../../data/slice';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
describe('Sequence', () => {
let mockData;
@@ -42,10 +46,14 @@ describe('Sequence', () => {
it('renders correctly without data', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
render(<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />, { store: testStore });
render(
<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />,
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders correctly for gated content', async () => {
@@ -70,12 +78,14 @@ describe('Sequence', () => {
}, false);
const { container } = render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
// `Previous`, `Active`, `Next`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button').length).toEqual(5);
// `Previous`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button').length).toEqual(3);
// `Active` and `Next` buttons.
expect(screen.getAllByRole('link').length).toEqual(2);
expect(screen.getByText('Content Locked')).toBeInTheDocument();
const unitContainer = container.querySelector('.unit-container');
@@ -101,7 +111,7 @@ describe('Sequence', () => {
}, false);
render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
await waitFor(() => {
@@ -112,26 +122,30 @@ describe('Sequence', () => {
// No normal content or navigation should be rendered. Just the above alert.
expect(screen.queryAllByRole('button').length).toEqual(0);
expect(screen.queryAllByRole('link').length).toEqual(1);
});
it('displays error message on sequence load failure', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId }));
render(<Sequence {...mockData} />, { store: testStore });
render(<Sequence {...mockData} />, { store: testStore, wrapWithRouter: true });
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
});
it('handles loading unit', async () => {
render(<Sequence {...mockData} />);
render(<Sequence {...mockData} />, { wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// Renders navigation buttons plus one button for each unit.
expect(screen.getAllByRole('button')).toHaveLength(4 + unitBlocks.length);
// `Previous`, `Bookmark` and `Close Tray` buttons
expect(screen.getAllByRole('button')).toHaveLength(3);
// Renders `Next` button plus one button for each unit.
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// At this point there will be 2 `Previous` and 2 `Next` buttons.
expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
});
describe('sequence and unit navigation buttons', () => {
@@ -160,10 +174,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[1].id,
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
fireEvent.click(sequencePreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
@@ -176,7 +190,7 @@ describe('Sequence', () => {
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i })
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
.filter(button => button !== sequencePreviousButton)[0];
fireEvent.click(unitPreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
@@ -196,10 +210,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id,
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequenceNextButton = screen.getByRole('button', { name: /next/i });
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
fireEvent.click(sequenceNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
@@ -211,7 +225,7 @@ describe('Sequence', () => {
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitNextButton = screen.getAllByRole('button', { name: /next/i })
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
.filter(button => button !== sequenceNextButton)[0];
fireEvent.click(unitNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
@@ -234,14 +248,14 @@ describe('Sequence', () => {
previousSequenceHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
fireEvent.click(screen.getByRole('button', { name: /next/i }));
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
// As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here.
// Therefore the next unit will still be `the initial one + 1`.
@@ -258,7 +272,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -277,7 +291,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -319,15 +333,15 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: innerTestStore });
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
@@ -367,10 +381,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id,
unitNavigationHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
current_tab: currentTabNumber,
@@ -394,13 +408,13 @@ describe('Sequence', () => {
describe('notification feature', () => {
it('renders notification tray in sequence', async () => {
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
expect(await screen.findByText('Notifications')).toBeInTheDocument();
});
it('handles click on notification tray close button', async () => {
const toggleNotificationTray = jest.fn();
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />);
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
fireEvent.click(notificationCloseIconButton);
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
@@ -408,7 +422,7 @@ describe('Sequence', () => {
it('does not render notification tray in sequence by default if in responsive view', async () => {
global.innerWidth = breakpoints.medium.maxWidth;
const { container } = render(<Sequence {...mockData} />);
const { container } = render(<Sequence {...mockData} />, { wrapWithRouter: true });
// unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead:
expect(container).not.toHaveClass('notification-tray-container');
});

View File

@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
});
it('displays loading message', () => {
render(<SequenceContent {...mockData} />);
render(<SequenceContent {...mockData} />, { wrapWithRouter: true });
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
});
it('displays messages for the locked content', async () => {
const { gatedContent } = store.getState().models.sequences[mockData.sequenceId];
const { container } = render(<SequenceContent {...mockData} gated />);
const { container } = render(<SequenceContent {...mockData} gated />, { wrapWithRouter: true });
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
expect(await screen.findByText('Content Locked')).toBeInTheDocument();
@@ -38,7 +38,7 @@ describe('Sequence Content', () => {
});
it('displays message for no content', () => {
render(<SequenceContent {...mockData} unitId={null} />);
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true });
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
});
});

View File

@@ -1,269 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext, ErrorPage } from '@edx/frontend-platform/react';
import { Modal } from '@edx/paragon';
import PropTypes from 'prop-types';
import React, {
Suspense, useCallback, useContext, useEffect, useLayoutEffect, useState,
} from 'react';
import { useDispatch } from 'react-redux';
import { processEvent } from '../../../course-home/data/thunks';
import { useEventListener } from '../../../generic/hooks';
import { useModel } from '../../../generic/model-store';
import PageLoading from '../../../generic/PageLoading';
import { fetchCourse } from '../../data';
import BookmarkButton from '../bookmark/BookmarkButton';
import ShareButton from '../share/ShareButton';
import messages from './messages';
const HonorCode = React.lazy(() => import('./honor-code'));
const LockPaywall = React.lazy(() => import('./lock-paywall'));
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
*
* We must use the wildcard (*) origin for each feature, as courseware content
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
* block that iframes external course content.
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
);
/**
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
* useEffect hooks until the user interacts with the page again. This is particularly confusing
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
* state.
*
* We were able to solve this error by using a layout effect to update some component state, which
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
* a joke) one here so it wouldn't be accidentally removed elsewhere.
*
* If we remove this hook when one of these happens:
* 1. React figures out that there's an issue here and fixes a bug.
* 2. We cease to use an iframe for unit rendering.
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
* 4. We stop supporting Firefox.
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
* Firefox/React for review, and they kindly help us figure out what in the world is happening
* so we can fix it.
*
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
*/
function useLoadBearingHook(id) {
const setValue = useState(0)[1];
useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
}
export function sendUrlHashToFrame(frame) {
const { hash } = window.location;
if (hash) {
// The url hash will be sent to LMS-served iframe in order to find the location of the
// hash within the iframe.
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
}
}
const Unit = ({
courseId,
format,
onLoaded,
id,
intl,
}) => {
const { authenticatedUser } = useContext(AppContext);
const view = authenticatedUser ? 'student_view' : 'public_view';
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
if (format) {
iframeUrl += `&format=${format}`;
}
const [iframeHeight, setIframeHeight] = useState(0);
const [hasLoaded, setHasLoaded] = useState(false);
const [showError, setShowError] = useState(false);
const [modalOptions, setModalOptions] = useState({ open: false });
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
const [windowTopOffset, setWindowTopOffset] = useState(null);
const unit = useModel('units', id);
const course = useModel('coursewareMeta', courseId);
const {
contentTypeGatingEnabled,
userNeedsIntegritySignature,
} = course;
const dispatch = useDispatch();
// Do not remove this hook. See function description.
useLoadBearingHook(id);
useEffect(() => {
if (userNeedsIntegritySignature && unit.graded) {
setShouldDisplayHonorCode(true);
} else {
setShouldDisplayHonorCode(false);
}
}, [userNeedsIntegritySignature]);
const receiveMessage = useCallback(({ data }) => {
const {
type,
payload,
} = data;
if (type === 'plugin.resize') {
setIframeHeight(payload.height);
// We observe exit from the video xblock full screen mode
// and do page scroll to the previously saved scroll position
if (windowTopOffset !== null) {
window.scrollTo(0, Number(windowTopOffset));
}
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
if (onLoaded) {
onLoaded();
}
}
} else if (type === 'plugin.modal') {
payload.open = true;
setModalOptions(payload);
} else if (type === 'plugin.videoFullScreen') {
// We listen for this message from LMS to know when we need to
// save or reset scroll position on toggle video xblock full screen mode.
setWindowTopOffset(payload.open ? window.scrollY : null);
} else if (data.offset) {
// We listen for this message from LMS to know when the page needs to
// be scrolled to another location on the page.
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
}
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded, setWindowTopOffset, windowTopOffset]);
useEventListener('message', receiveMessage);
useEffect(() => {
sendUrlHashToFrame(document.getElementById('unit-iframe'));
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
return (
<div className="unit">
<h1 className="mb-0 h3">{unit.title}</h1>
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
<BookmarkButton
unitId={unit.id}
isBookmarked={unit.bookmarked}
isProcessing={unit.bookmarkedUpdateState === 'loading'}
/>
{/* TODO: social share exp. Need to remove later */}
{(window.expSocialShareAboutUrls && window.expSocialShareAboutUrls[unit.id] !== undefined) && (
<ShareButton url={window.expSocialShareAboutUrls[unit.id]} />
)}
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages.loadingLockedContent)}
/>
)}
>
<LockPaywall courseId={courseId} />
</Suspense>
)}
{shouldDisplayHonorCode && (
<Suspense
fallback={(
<PageLoading
srMessage={intl.formatMessage(messages.loadingHonorCode)}
/>
)}
>
<HonorCode courseId={courseId} />
</Suspense>
)}
{!shouldDisplayHonorCode && !hasLoaded && !showError && (
<PageLoading
srMessage={intl.formatMessage(messages.loadingSequence)}
/>
)}
{!shouldDisplayHonorCode && !hasLoaded && showError && (
<ErrorPage />
)}
{modalOptions.open && (
<Modal
body={(
<>
{modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
: (
<iframe
title={modalOptions.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{
width: '100%',
height: '100vh',
}}
/>
)}
</>
)}
onClose={() => { setModalOptions({ open: false }); }}
open
dialogClassName="modal-lti"
/>
)}
{!shouldDisplayHonorCode && (
<div className="unit-iframe-wrapper">
<iframe
id="unit-iframe"
title={unit.title}
src={iframeUrl}
allow={IFRAME_FEATURE_POLICY}
allowFullScreen
height={iframeHeight}
scrolling="no"
referrerPolicy="origin"
onLoad={() => {
// onLoad *should* only fire after everything in the iframe has finished its own load events.
// Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
// for a successful load. If it *has not fired*, we are in an error state. For example, the backend
// could have given us a 4xx or 5xx response.
if (!hasLoaded) {
setShowError(true);
}
window.onmessage = (e) => {
if (e.data.event_name) {
dispatch(processEvent(e.data, fetchCourse));
}
};
}}
/>
</div>
)}
</div>
);
};
Unit.propTypes = {
courseId: PropTypes.string.isRequired,
format: PropTypes.string,
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLoaded: PropTypes.func,
};
Unit.defaultProps = {
format: null,
onLoaded: undefined,
};
export default injectIntl(Unit);

View File

@@ -1,192 +0,0 @@
import React from 'react';
import { Factory } from 'rosie';
import {
initializeTestStore, loadUnit, messageEvent, render, screen, waitFor,
} from '../../../setupTest';
import Unit, { sendUrlHashToFrame } from './Unit';
describe('Unit', () => {
let mockData;
const courseMetadata = Factory.build(
'courseMetadata',
{ content_type_gating_enabled: true },
);
const courseMetadataNeedsSignature = Factory.build(
'courseMetadata',
{ user_needs_integrity_signature: true },
);
const unitBlocks = [
Factory.build(
'block',
{ type: 'vertical', graded: 'true' },
{ courseId: courseMetadata.id },
), Factory.build(
'block',
{
type: 'vertical',
contains_content_type_gated_content: true,
bookmarked: true,
graded: true,
},
{ courseId: courseMetadata.id },
),
Factory.build(
'block',
{ type: 'vertical', graded: false },
{ courseId: courseMetadata.id },
),
];
const [unit, unitThatContainsGatedContent, ungradedUnit] = unitBlocks;
beforeAll(async () => {
await initializeTestStore({ courseMetadata, unitBlocks });
mockData = {
id: unit.id,
courseId: courseMetadata.id,
format: 'Homework',
};
});
it('renders correctly', () => {
render(<Unit {...mockData} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
const renderedUnit = screen.getByTitle(unit.display_name);
expect(renderedUnit).toHaveAttribute('height', String(0));
expect(renderedUnit).toHaveAttribute('src', `http://localhost:18000/xblock/${mockData.id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view&format=${mockData.format}`);
});
it('renders proper message for gated content', () => {
render(<Unit {...mockData} id={unitThatContainsGatedContent.id} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
});
it('does not display HonorCode for ungraded units', async () => {
const signatureStore = await initializeTestStore(
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
false,
);
const signatureData = {
id: ungradedUnit.id,
courseId: courseMetadataNeedsSignature.id,
format: 'Homework',
};
render(<Unit {...signatureData} />, { store: signatureStore });
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
});
it('displays HonorCode for graded units if user needs integrity signature', async () => {
const signatureStore = await initializeTestStore(
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
false,
);
const signatureData = {
id: unit.id,
courseId: courseMetadataNeedsSignature.id,
format: 'Homework',
};
render(<Unit {...signatureData} />, { store: signatureStore });
expect(screen.getByText('Loading honor code messaging...')).toBeInTheDocument();
});
it('handles receiving MessageEvent', async () => {
render(<Unit {...mockData} />);
loadUnit();
// Loading message is gone now.
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// Iframe's height is set via message.
expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height));
});
it('calls onLoaded after receiving MessageEvent', async () => {
const onLoaded = jest.fn();
render(<Unit {...mockData} {...{ onLoaded }} />);
loadUnit();
await waitFor(() => expect(onLoaded).toHaveBeenCalledTimes(1));
});
it('resizes iframe on second MessageEvent, does not call onLoaded again', async () => {
const onLoaded = jest.fn();
// Clone message and set different height.
const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 200 } };
render(<Unit {...mockData} {...{ onLoaded }} />);
loadUnit();
await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height)));
window.postMessage(testMessageWithOtherHeight, '*');
await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(testMessageWithOtherHeight.payload.height)));
expect(onLoaded).toHaveBeenCalledTimes(1);
});
it('scrolls page on MessagaeEvent when receiving offset', async () => {
// Set message to constain offset data.
const testMessageWithOffset = { offset: 1500 };
render(<Unit {...mockData} />);
window.postMessage(testMessageWithOffset, '*');
await expect(waitFor(() => expect(window.scrollTo()).toHaveBeenCalled()));
expect(window.scrollY === testMessageWithOffset.offset);
});
it('scrolls page on MessagaeEvent when receiving videoFullScreen state', async () => {
// Set message to constain video full screen data.
const defaultTopOffset = 800;
const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 500 } };
const testMessageWithFullscreenState = (isOpen) => ({ type: 'plugin.videoFullScreen', payload: { open: isOpen } });
render(<Unit {...mockData} />);
Object.defineProperty(window, 'scrollY', { value: defaultTopOffset, writable: true });
window.postMessage(testMessageWithFullscreenState(true), '*');
window.postMessage(testMessageWithFullscreenState(false), '*');
window.postMessage(testMessageWithOtherHeight, '*');
await expect(waitFor(() => expect(window.scrollTo()).toHaveBeenCalledTimes(1)));
expect(window.scrollY === defaultTopOffset);
});
it('ignores MessageEvent with unhandled type', async () => {
// Clone message and set different type.
const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' };
render(<Unit {...mockData} />);
window.postMessage(testMessageWithUnhandledType, '*');
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
await expect(waitFor(
() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(testMessageWithUnhandledType.payload.height)),
{ timeout: 100 },
)).rejects.toThrowError(/Expected the element to have attribute/);
});
it('scrolls to correct place onLoad', () => {
document.body.innerHTML = "<iframe id='unit-iframe' />";
const mockHashCheck = jest.fn(frameVar => sendUrlHashToFrame(frameVar));
const frame = document.getElementById('unit-iframe');
const originalWindow = { ...window };
const windowSpy = jest.spyOn(global, 'window', 'get');
windowSpy.mockImplementation(() => ({
...originalWindow,
location: {
...originalWindow.location,
hash: '#test',
},
}));
const messageSpy = jest.spyOn(frame.contentWindow, 'postMessage');
messageSpy.mockImplementation(() => ({ hashName: originalWindow.location.hash }));
mockHashCheck(frame);
expect(mockHashCheck).toHaveBeenCalled();
expect(messageSpy).toHaveBeenCalled();
windowSpy.mockRestore();
});
it('calls useEffect and checkForHash', () => {
const mockHashCheck = jest.fn(() => sendUrlHashToFrame());
const effectSpy = jest.spyOn(React, 'useEffect');
effectSpy.mockImplementation(() => mockHashCheck());
render(<Unit {...mockData} />);
expect(React.useEffect).toHaveBeenCalled();
expect(mockHashCheck).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,114 @@
import PropTypes from 'prop-types';
import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react';
import { StrictDict } from '@edx/react-unit-test-utils';
import { Modal } from '@edx/paragon';
import PageLoading from '../../../../generic/PageLoading';
import * as hooks from './hooks';
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
*
* We must use the wildcard (*) origin for each feature, as courseware content
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
* block that iframes external course content.
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
);
export const testIDs = StrictDict({
contentIFrame: 'content-iframe-test-id',
modalIFrame: 'modal-iframe-test-id',
});
const ContentIFrame = ({
iframeUrl,
shouldShowContent,
loadingMessage,
id,
elementId,
onLoaded,
title,
}) => {
const {
handleIFrameLoad,
hasLoaded,
iframeHeight,
showError,
} = hooks.useIFrameBehavior({
elementId,
id,
iframeUrl,
onLoaded,
});
const {
modalOptions,
handleModalClose,
} = hooks.useModalIFrameData();
const contentIFrameProps = {
id: elementId,
src: iframeUrl,
allow: IFRAME_FEATURE_POLICY,
allowFullScreen: true,
height: iframeHeight,
scrolling: 'no',
referrerPolicy: 'origin',
onLoad: handleIFrameLoad,
};
return (
<>
{(shouldShowContent && !hasLoaded) && (
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
)}
{shouldShowContent && (
<div className="unit-iframe-wrapper">
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
</div>
)}
{modalOptions.isOpen && (
<Modal
body={modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
: (
<iframe
title={modalOptions.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{ width: '100%', height: modalOptions.height }}
/>
)}
dialogClassName="modal-lti"
onClose={handleModalClose}
open
/>
)}
</>
);
};
ContentIFrame.propTypes = {
iframeUrl: PropTypes.string,
id: PropTypes.string.isRequired,
shouldShowContent: PropTypes.bool.isRequired,
loadingMessage: PropTypes.node.isRequired,
elementId: PropTypes.string.isRequired,
onLoaded: PropTypes.func,
title: PropTypes.node.isRequired,
};
ContentIFrame.defaultProps = {
iframeUrl: null,
onLoaded: () => ({}),
};
export default ContentIFrame;

View File

@@ -0,0 +1,175 @@
import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react';
import { Modal } from '@edx/paragon';
import { shallow } from '@edx/react-unit-test-utils';
import PageLoading from '../../../../generic/PageLoading';
import * as hooks from './hooks';
import ContentIFrame, { IFRAME_FEATURE_POLICY, testIDs } from './ContentIFrame';
jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: 'ErrorPage' }));
jest.mock('@edx/paragon', () => ({ Modal: 'Modal' }));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({
useIFrameBehavior: jest.fn(),
useModalIFrameData: jest.fn(),
}));
const iframeBehavior = {
handleIFrameLoad: jest.fn().mockName('IFrameBehavior.handleIFrameLoad'),
hasLoaded: false,
iframeHeight: 20,
showError: false,
};
const modalOptions = {
closed: {
isOpen: false,
},
withBody: {
body: 'test-body',
isOpen: true,
},
withUrl: {
isOpen: true,
title: 'test-modal-title',
url: 'test-modal-url',
height: 'test-height',
},
};
const modalIFrameData = {
modalOptions: modalOptions.closed,
handleModalClose: jest.fn().mockName('modalIFrameOptions.handleModalClose'),
};
hooks.useIFrameBehavior.mockReturnValue(iframeBehavior);
hooks.useModalIFrameData.mockReturnValue(modalIFrameData);
const props = {
iframeUrl: 'test-iframe-url',
shouldShowContent: true,
loadingMessage: 'test-loading-message',
id: 'test-id',
elementId: 'test-element-id',
onLoaded: jest.fn().mockName('props.onLoaded'),
title: 'test-title',
};
let el;
describe('ContentIFrame Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('behavior', () => {
beforeEach(() => {
el = shallow(<ContentIFrame {...props} />);
});
it('initializes iframe behavior hook', () => {
expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({
elementId: props.elementId,
id: props.id,
iframeUrl: props.iframeUrl,
onLoaded: props.onLoaded,
});
});
it('initializes modal iframe data', () => {
expect(hooks.useModalIFrameData).toHaveBeenCalledWith();
});
});
describe('output', () => {
let component;
describe('if shouldShowContent', () => {
describe('if not hasLoaded', () => {
it('displays errorPage if showError', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(ErrorPage).length).toEqual(1);
});
it('displays PageLoading component if not showError', () => {
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(PageLoading);
expect(component.props.srMessage).toEqual(props.loadingMessage);
});
});
describe('hasLoaded', () => {
it('does not display PageLoading or ErrorPage', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true });
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(PageLoading).length).toEqual(0);
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
});
});
it('display iframe with props from hooks', () => {
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByTestId(testIDs.contentIFrame);
expect(component.props).toEqual({
allow: IFRAME_FEATURE_POLICY,
allowFullScreen: true,
scrolling: 'no',
referrerPolicy: 'origin',
title: props.title,
id: props.elementId,
src: props.iframeUrl,
height: iframeBehavior.iframeHeight,
onLoad: iframeBehavior.handleIFrameLoad,
'data-testid': testIDs.contentIFrame,
});
});
});
describe('if not shouldShowContent', () => {
it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => {
el = shallow(<ContentIFrame {...{ ...props, shouldShowContent: false }} />);
expect(el.instance.findByType(PageLoading).length).toEqual(0);
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0);
});
});
it('does not display modal if modalOptions returns isOpen: false', () => {
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(Modal).length).toEqual(0);
});
describe('if modalOptions.isOpen', () => {
const testModalOpenAndHandleClose = () => {
test('Modal component isOpen, with handleModalClose from hook', () => {
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
});
};
describe('body modal', () => {
beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody });
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(Modal);
});
it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
expect(component.props.body).toEqual(<div className="unit-modal">{modalOptions.withBody.body}</div>);
});
testModalOpenAndHandleClose();
});
describe('url modal', () => {
beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withUrl });
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(Modal);
});
testModalOpenAndHandleClose();
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
expect(component.props.body).toEqual(
<iframe
title={modalOptions.withUrl.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.withUrl.url}
style={{ width: '100%', height: modalOptions.withUrl.height }}
/>,
);
});
});
});
});
});

View File

@@ -0,0 +1,50 @@
import React, { Suspense } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
import PageLoading from '../../../../generic/PageLoading';
import messages from '../messages';
import HonorCode from '../honor-code';
import LockPaywall from '../lock-paywall';
import * as hooks from './hooks';
import { modelKeys } from './constants';
const UnitSuspense = ({
courseId,
id,
}) => {
const { formatMessage } = useIntl();
const shouldDisplayHonorCode = hooks.useShouldDisplayHonorCode({ courseId, id });
const unit = useModel(modelKeys.units, id);
const meta = useModel(modelKeys.coursewareMeta, courseId);
const shouldDisplayContentGating = (
meta.contentTypeGatingEnabled && unit.containsContentTypeGatedContent
);
const suspenseComponent = (message, Component) => (
<Suspense fallback={<PageLoading srMessage={formatMessage(message)} />}>
<Component courseId={courseId} />
</Suspense>
);
return (
<>
{shouldDisplayContentGating && (
suspenseComponent(messages.loadingLockedContent, LockPaywall)
)}
{shouldDisplayHonorCode && (
suspenseComponent(messages.loadingHonorCode, HonorCode)
)}
</>
);
};
UnitSuspense.propTypes = {
courseId: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
};
export default UnitSuspense;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
import { useModel } from '../../../../generic/model-store';
import PageLoading from '../../../../generic/PageLoading';
import messages from '../messages';
import HonorCode from '../honor-code';
import LockPaywall from '../lock-paywall';
import hooks from './hooks';
import { modelKeys } from './constants';
import UnitSuspense from './UnitSuspense';
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
useIntl: () => ({ formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage }),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
Suspense: 'Suspense',
}));
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../../../../generic/model-store', () => ({ useModel: jest.fn() }));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({
useShouldDisplayHonorCode: jest.fn(() => false),
}));
const mockModels = (enabled, containsContent) => {
useModel.mockImplementation((key) => (
key === modelKeys.units
? { containsContentTypeGatedContent: containsContent }
: { contentTypeGatingEnabled: enabled }
));
};
const props = {
courseId: 'test-course-id',
id: 'test-id',
};
let el;
describe('UnitSuspense component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockModels(false, false);
});
describe('behavior', () => {
it('initializes models', () => {
el = shallow(<UnitSuspense {...props} />);
const { calls } = useModel.mock;
const [unitCall] = calls.filter(call => call[0] === modelKeys.units);
const [metaCall] = calls.filter(call => call[0] === modelKeys.coursewareMeta);
expect(unitCall[1]).toEqual(props.id);
expect(metaCall[1]).toEqual(props.courseId);
});
});
describe('output', () => {
describe('LockPaywall', () => {
const testNoPaywall = () => {
it('does not display LockPaywal', () => {
el = shallow(<UnitSuspense {...props} />);
expect(el.instance.findByType(LockPaywall).length).toEqual(0);
});
};
describe('gating not enabled', () => { testNoPaywall(); });
describe('gating enabled, but no gated content included', () => {
beforeEach(() => { mockModels(true, false); });
testNoPaywall();
});
describe('gating enabled, gated content included', () => {
beforeEach(() => { mockModels(true, true); });
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
el = shallow(<UnitSuspense {...props} />);
const [component] = el.instance.findByType(LockPaywall);
expect(component.parent.type).toEqual('Suspense');
expect(component.parent.props.fallback)
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />);
expect(component.props.courseId).toEqual(props.courseId);
});
});
});
describe('HonorCode', () => {
it('does not display HonorCode if useShouldDisplayHonorCode => false', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false);
el = shallow(<UnitSuspense {...props} />);
expect(el.instance.findByType(HonorCode).length).toEqual(0);
});
it('displays HonorCode component in Suspense wrapper with PageLoading fallback if shouldDisplayHonorCode', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
el = shallow(<UnitSuspense {...props} />);
const [component] = el.instance.findByType(HonorCode);
expect(component.parent.type).toEqual('Suspense');
expect(component.parent.props.fallback)
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />);
expect(component.props.courseId).toEqual(props.courseId);
});
});
});
});

View File

@@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Unit component output BookmarkButton props bookmarked, bookmark update pending snapshot 1`] = `
<BookmarkButton
isBookmarked={true}
isProcessing={false}
unitId="unit-id"
/>
`;
exports[`Unit component output BookmarkButton props not bookmarked, bookmark update loading snapshot 1`] = `
<BookmarkButton
isBookmarked={false}
isProcessing={true}
unitId="unit-id"
/>
`;
exports[`Unit component output snapshot: not bookmarked, do not show content 1`] = `
<div
className="unit"
>
<h1
className="mb-0 h3"
>
unit-title
</h1>
<h2
className="sr-only"
>
Level 2 headings may be created by course providers in the future.
</h2>
<BookmarkButton
isBookmarked={false}
isProcessing={false}
unitId="unit-id"
/>
<UnitSuspense
courseId="test-course-id"
id="test-props-id"
/>
<ContentIFrame
elementId="unit-iframe"
id="test-props-id"
loadingMessage="Loading learning sequence..."
onLoaded={[MockFunction props.onLoaded]}
shouldShowContent={true}
title="unit-title"
/>
</div>
`;

View File

@@ -0,0 +1,26 @@
import { StrictDict } from '@edx/react-unit-test-utils/dist';
export const modelKeys = StrictDict({
units: 'units',
coursewareMeta: 'coursewareMeta',
});
export const views = StrictDict({
student: 'student_view',
public: 'public_view',
});
export const loadingState = 'loading';
export const messageTypes = StrictDict({
modal: 'plugin.modal',
resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen',
});
export default StrictDict({
modelKeys,
views,
loadingState,
messageTypes,
});

View File

@@ -0,0 +1,5 @@
export { default as useExamAccess } from './useExamAccess';
export { default as useIFrameBehavior } from './useIFrameBehavior';
export { default as useLoadBearingHook } from './useLoadBearingHook';
export { default as useModalIFrameData } from './useModalIFrameData';
export { default as useShouldDisplayHonorCode } from './useShouldDisplayHonorCode';

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { logError } from '@edx/frontend-platform/logging';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
export const stateKeys = StrictDict({
accessToken: 'accessToken',
blockAccess: 'blockAccess',
});
const useExamAccess = ({
id,
}) => {
const [accessToken, setAccessToken] = useKeyedState(stateKeys.accessToken, '');
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam());
React.useEffect(() => {
if (isExam()) {
fetchExamAccess()
.finally(() => {
const examAccess = getExamAccess();
setAccessToken(examAccess);
setBlockAccess(false);
})
.catch((error) => {
logError(error);
});
}
}, [id]);
return {
blockAccess,
accessToken,
};
};
export default useExamAccess;

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { logError } from '@edx/frontend-platform/logging';
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
import { isEqual } from 'lodash';
import { waitFor } from '../../../../../setupTest';
import useExamAccess, { stateKeys } from './useExamAccess';
const getEffect = (prereqs) => {
const { calls } = React.useEffect.mock;
const match = calls.filter(call => isEqual(call[1], prereqs));
return match.length ? match[0][0] : null;
};
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('@edx/frontend-lib-special-exams', () => ({
getExamAccess: jest.fn(),
fetchExamAccess: jest.fn(),
isExam: jest.fn(() => false),
}));
const state = mockUseKeyedState(stateKeys);
const id = 'test-id';
const mockFetchExamAccess = Promise.resolve();
fetchExamAccess.mockReturnValue(mockFetchExamAccess);
const testAccessToken = 'test-access-token';
getExamAccess.mockReturnValue(testAccessToken);
describe('useExamAccess hook', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('behavior', () => {
it('initializes access token to empty string', () => {
useExamAccess({ id });
state.expectInitializedWith(stateKeys.accessToken, '');
});
it('initializes blockAccess to true if is an exam', () => {
useExamAccess({ id });
state.expectInitializedWith(stateKeys.blockAccess, false);
});
it('initializes blockAccess to false if is not an exam', () => {
isExam.mockReturnValueOnce(true);
useExamAccess({ id });
state.expectInitializedWith(stateKeys.blockAccess, true);
});
describe('effects - on id change', () => {
let useEffectCb;
beforeEach(() => {
useExamAccess({ id });
useEffectCb = getEffect([id], React);
});
it('does not call fetchExamAccess if not an exam', () => {
useEffectCb();
expect(fetchExamAccess).not.toHaveBeenCalled();
});
it('fetches and sets exam access if isExam', async () => {
isExam.mockReturnValueOnce(true);
useEffectCb();
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
state.expectSetStateCalledWith(stateKeys.accessToken, testAccessToken);
state.expectSetStateCalledWith(stateKeys.blockAccess, false);
});
const testError = 'test-error';
it('logs error if fetchExamAccess fails', async () => {
isExam.mockReturnValueOnce(true);
fetchExamAccess.mockReturnValueOnce(Promise.reject(testError));
useEffectCb();
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
expect(logError).toHaveBeenCalledWith(testError);
});
});
});
describe('output', () => {
it('forwards blockAccess and accessToken from state fields', () => {
const testBlockAccess = 'test-block-access';
state.mockVals({
blockAccess: testBlockAccess,
accessToken: testAccessToken,
});
const out = useExamAccess({ id });
expect(out.blockAccess).toEqual(testBlockAccess);
expect(out.accessToken).toEqual(testAccessToken);
state.resetVals();
});
});
});

View File

@@ -0,0 +1,116 @@
import { getConfig } from '@edx/frontend-platform';
import React from 'react';
import { useDispatch } from 'react-redux';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { fetchCourse } from '../../../../data';
import { processEvent } from '../../../../../course-home/data/thunks';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useLoadBearingHook from './useLoadBearingHook';
export const stateKeys = StrictDict({
iframeHeight: 'iframeHeight',
hasLoaded: 'hasLoaded',
showError: 'showError',
windowTopOffset: 'windowTopOffset',
});
const useIFrameBehavior = ({
elementId,
id,
iframeUrl,
onLoaded,
}) => {
// Do not remove this hook. See function description.
useLoadBearingHook(id);
const dispatch = useDispatch();
const [iframeHeight, setIframeHeight] = useKeyedState(stateKeys.iframeHeight, 0);
const [hasLoaded, setHasLoaded] = useKeyedState(stateKeys.hasLoaded, false);
const [showError, setShowError] = useKeyedState(stateKeys.showError, false);
const [windowTopOffset, setWindowTopOffset] = useKeyedState(stateKeys.windowTopOffset, null);
React.useEffect(() => {
const frame = document.getElementById(elementId);
const { hash } = window.location;
if (hash) {
// The url hash will be sent to LMS-served iframe in order to find the location of the
// hash within the iframe.
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
}
}, [id, onLoaded, iframeHeight, hasLoaded]);
const receiveMessage = React.useCallback(({ data }) => {
const { type, payload } = data;
if (type === messageTypes.resize) {
setIframeHeight(payload.height);
// We observe exit from the video xblock fullscreen mode
// and scroll to the previously saved scroll position
if (windowTopOffset !== null) {
window.scrollTo(0, Number(windowTopOffset));
}
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
if (onLoaded) {
onLoaded();
}
}
} else if (type === messageTypes.videoFullScreen) {
// We listen for this message from LMS to know when we need to
// save or reset scroll position on toggle video xblock fullscreen mode
setWindowTopOffset(payload.open ? window.scrollY : null);
} else if (data.offset) {
// We listen for this message from LMS to know when the page needs to
// be scrolled to another location on the page.
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
}
}, [
id,
onLoaded,
hasLoaded,
setHasLoaded,
iframeHeight,
setIframeHeight,
windowTopOffset,
setWindowTopOffset,
]);
useEventListener('message', receiveMessage);
/**
* onLoad *should* only fire after everything in the iframe has finished its own load events.
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
* for a successful load. If it *has not fired*, we are in an error state. For example, the backend
* could have given us a 4xx or 5xx response.
*/
const handleIFrameLoad = () => {
if (!hasLoaded) {
setShowError(true);
logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', {
iframeUrl,
});
}
window.onmessage = (e) => {
if (e.data.event_name) {
dispatch(processEvent(e.data, fetchCourse));
}
};
};
return {
iframeHeight,
handleIFrameLoad,
showError,
hasLoaded,
};
};
export default useIFrameBehavior;

View File

@@ -0,0 +1,295 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { getConfig } from '@edx/frontend-platform';
import { fetchCourse } from '../../../../data';
import { processEvent } from '../../../../../course-home/data/thunks';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useIFrameBehavior, { stateKeys } from './useIFrameBehavior';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
}));
jest.mock('./useLoadBearingHook', () => jest.fn());
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('../../../../data', () => ({
fetchCourse: jest.fn(),
}));
jest.mock('../../../../../course-home/data/thunks', () => ({
processEvent: jest.fn((...args) => ({ processEvent: args })),
}));
jest.mock('../../../../../generic/hooks', () => ({
useEventListener: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
const props = {
elementId: 'test-element-id',
id: 'test-id',
iframeUrl: 'test-iframe-url',
onLoaded: jest.fn(),
};
const testIFrameHeight = 42;
const config = { LMS_BASE_URL: 'test-base-url' };
getConfig.mockReturnValue(config);
const dispatch = jest.fn();
useDispatch.mockReturnValue(dispatch);
const postMessage = jest.fn();
const frame = { contentWindow: { postMessage } };
const mockGetElementById = jest.fn(() => frame);
const testHash = '#test-hash';
const defaultStateVals = {
iframeHeight: 0,
hasLoaded: false,
showError: false,
windowTopOffset: null,
};
const stateVals = {
iframeHeight: testIFrameHeight,
hasLoaded: true,
showError: true,
windowTopOffset: 32,
};
describe('useIFrameBehavior hook', () => {
let hook;
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
describe('behavior', () => {
it('initializes iframe height to 0 and error/loaded values to false', () => {
hook = useIFrameBehavior(props);
state.expectInitializedWith(stateKeys.iframeHeight, 0);
state.expectInitializedWith(stateKeys.hasLoaded, false);
state.expectInitializedWith(stateKeys.showError, false);
state.expectInitializedWith(stateKeys.windowTopOffset, null);
});
describe('effects - on frame change', () => {
let oldGetElement;
beforeEach(() => {
global.window ??= Object.create(window);
Object.defineProperty(window, 'location', { value: {}, writable: true });
state.mockVals(stateVals);
oldGetElement = document.getElementById;
document.getElementById = mockGetElementById;
});
afterEach(() => {
state.resetVals();
document.getElementById = oldGetElement;
});
it('does not post url hash if the window does not have one', () => {
hook = useIFrameBehavior(props);
const cb = getEffects([
props.id,
props.onLoaded,
testIFrameHeight,
true,
], React)[0];
cb();
expect(postMessage).not.toHaveBeenCalled();
});
it('posts url hash if the window has one', () => {
window.location.hash = testHash;
hook = useIFrameBehavior(props);
const cb = getEffects([
props.id,
props.onLoaded,
testIFrameHeight,
true,
], React)[0];
cb();
expect(postMessage).toHaveBeenCalledWith({ hashName: testHash }, config.LMS_BASE_URL);
});
});
describe('event listener', () => {
it('calls eventListener with prepared callback', () => {
state.mockVals(stateVals);
hook = useIFrameBehavior(props);
const [call] = useEventListener.mock.calls;
expect(call[0]).toEqual('message');
expect(call[1].prereqs).toEqual([
props.id,
props.onLoaded,
state.values.hasLoaded,
state.setState.hasLoaded,
state.values.iframeHeight,
state.setState.iframeHeight,
state.values.windowTopOffset,
state.setState.windowTopOffset,
]);
});
describe('resize message', () => {
const resizeMessage = (height = 23) => ({
data: { type: messageTypes.resize, payload: { height } },
});
const testSetIFrameHeight = (height = 23) => {
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage(height));
expect(state.setState.iframeHeight).toHaveBeenCalledWith(height);
};
const testOnlySetsHeight = () => {
it('sets iframe height with payload height', () => {
testSetIFrameHeight();
});
it('does not set hasLoaded', () => {
expect(state.setState.hasLoaded).not.toHaveBeenCalled();
});
};
describe('hasLoaded', () => {
beforeEach(() => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
});
testOnlySetsHeight();
});
describe('iframeHeight is not 0', () => {
beforeEach(() => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
});
testOnlySetsHeight();
});
describe('payload height is 0', () => {
beforeEach(() => { hook = useIFrameBehavior(props); });
testOnlySetsHeight(0);
});
describe('payload is present but uninitialized', () => {
it('sets iframe height with payload height', () => {
hook = useIFrameBehavior(props);
testSetIFrameHeight();
});
it('sets hasLoaded and calls onLoaded', () => {
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
expect(props.onLoaded).toHaveBeenCalled();
});
test('onLoaded is optional', () => {
hook = useIFrameBehavior({ ...props, onLoaded: undefined });
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
});
});
it('scrolls to current window vertical offset if one is set', () => {
const windowTopOffset = 32;
state.mockVals({ ...defaultStateVals, windowTopOffset });
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(window.scrollTo).toHaveBeenCalledWith(0, windowTopOffset);
});
it('does not scroll if towverticalp offset is not set', () => {
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(window.scrollTo).not.toHaveBeenCalled();
});
});
describe('video fullscreen message', () => {
let cb;
const scrollY = 23;
const fullScreenMessage = (open) => ({
data: { type: messageTypes.videoFullScreen, payload: { open } },
});
beforeEach(() => {
window.scrollY = scrollY;
hook = useIFrameBehavior(props);
[[, { cb }]] = useEventListener.mock.calls;
});
it('sets window top offset based on window.scrollY if opening the video', () => {
cb(fullScreenMessage(true));
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(scrollY);
});
it('sets window top offset to null if closing the video', () => {
cb(fullScreenMessage(false));
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(null);
});
});
describe('offset message', () => {
it('scrolls to data offset', () => {
const offsetTop = 44;
const mockGetEl = jest.fn(() => ({ offsetTop }));
const oldGetElement = document.getElementById;
document.getElementById = mockGetEl;
const oldScrollTo = window.scrollTo;
window.scrollTo = jest.fn();
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
const offset = 99;
cb({ data: { offset } });
expect(window.scrollTo).toHaveBeenCalledWith(0, offset + offsetTop);
expect(mockGetEl).toHaveBeenCalledWith('unit-iframe');
document.getElementById = oldGetElement;
window.scrollTo = oldScrollTo;
});
});
});
});
describe('output', () => {
describe('handleIFrameLoad', () => {
it('sets and logs error if has not loaded', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
expect(state.setState.showError).toHaveBeenCalledWith(true);
expect(logError).toHaveBeenCalled();
});
it('does not set/log errors if loaded', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
expect(state.setState.showError).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
});
it('registers an event handler to process fetchCourse events.', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
const eventName = 'test-event-name';
const event = { data: { event_name: eventName } };
window.onmessage(event);
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
});
});
it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => {
state.mockVals(stateVals);
hook = useIFrameBehavior(props);
expect(hook.iframeHeight).toEqual(stateVals.iframeHeight);
expect(hook.showError).toEqual(stateVals.showError);
expect(hook.hasLoaded).toEqual(stateVals.hasLoaded);
});
});
});

View File

@@ -0,0 +1,35 @@
import React from 'react';
/**
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
* useEffect hooks until the user interacts with the page again. This is particularly confusing
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
* state.
*
* We were able to solve this error by using a layout effect to update some component state, which
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
* a joke) one here so it wouldn't be accidentally removed elsewhere.
*
* If we remove this hook when one of these happens:
* 1. React figures out that there's an issue here and fixes a bug.
* 2. We cease to use an iframe for unit rendering.
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
* 4. We stop supporting Firefox.
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
* Firefox/React for review, and they kindly help us figure out what in the world is happening
* so we can fix it.
*
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
*/
const useLoadBearingHook = (id) => {
const setValue = React.useState(0)[1];
React.useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
};
export default useLoadBearingHook;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import useLoadBearingHook from './useLoadBearingHook';
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
useLayoutEffect: jest.fn(),
}));
const setState = jest.fn();
React.useState.mockImplementation((val) => [val, setState]);
const id = 'test-id';
describe('useLoadBearingHook', () => {
it('increments a simple value w/ useLayoutEffect', () => {
useLoadBearingHook(id);
expect(React.useState).toHaveBeenCalledWith(0);
const [[layoutCb, prereqs]] = React.useLayoutEffect.mock.calls;
expect(prereqs).toEqual([id]);
layoutCb();
const [[setValueCb]] = setState.mock.calls;
expect(setValueCb(1)).toEqual(2);
});
});

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useEventListener } from '../../../../../generic/hooks';
export const stateKeys = StrictDict({
isOpen: 'isOpen',
options: 'options',
});
export const DEFAULT_HEIGHT = '100vh';
const useModalIFrameBehavior = () => {
const [isOpen, setIsOpen] = useKeyedState(stateKeys.isOpen, false);
const [options, setOptions] = useKeyedState(stateKeys.options, { height: DEFAULT_HEIGHT });
const receiveMessage = React.useCallback(({ data }) => {
const { type, payload } = data;
if (type === 'plugin.modal') {
setOptions((current) => ({ ...current, ...payload }));
setIsOpen(true);
}
}, []);
useEventListener('message', receiveMessage);
const handleModalClose = () => {
setIsOpen(false);
};
return {
handleModalClose,
modalOptions: { isOpen, ...options },
};
};
export default useModalIFrameBehavior;

View File

@@ -0,0 +1,68 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useModalIFrameBehavior, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
}));
jest.mock('../../../../../generic/hooks', () => ({
useEventListener: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
describe('useModalIFrameBehavior', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('behavior', () => {
it('initializes isOpen to false', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.isOpen, false);
});
it('initializes options with default height', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.options, { height: DEFAULT_HEIGHT });
});
describe('eventListener', () => {
it('consumes modal events and opens sets modal options with open: true', () => {
const oldOptions = { some: 'old', options: 'yeah' };
state.mockVals({
[stateKeys.isOpen]: false,
[stateKeys.options]: oldOptions,
});
useModalIFrameBehavior();
expect(useEventListener).toHaveBeenCalled();
const { cb, prereqs } = useEventListener.mock.calls[0][1];
expect(prereqs).toEqual([]);
const payload = { test: 'values' };
cb({ data: { type: messageTypes.modal, payload } });
expect(state.setState.isOpen).toHaveBeenCalledWith(true);
expect(state.setState.options).toHaveBeenCalled();
const [[setOptionsCb]] = state.setState.options.mock.calls;
expect(setOptionsCb(oldOptions)).toEqual({ ...oldOptions, ...payload });
});
});
});
describe('output', () => {
test('handleModalClose sets modal options to closed', () => {
useModalIFrameBehavior().handleModalClose();
state.expectSetStateCalledWith(stateKeys.isOpen, false);
});
it('forwards modalOptions from state values', () => {
const modalOptions = { test: 'options' };
state.mockVals({
[stateKeys.options]: modalOptions,
[stateKeys.isOpen]: true,
});
expect(useModalIFrameBehavior().modalOptions).toEqual({
...modalOptions,
isOpen: true,
});
});
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useModel } from '../../../../../generic/model-store';
import { modelKeys } from '../constants';
export const stateKeys = StrictDict({
shouldDisplay: 'shouldDisplay',
});
/**
* @return {bool} should the honor code be displayed?
*/
const useShouldDisplayHonorCode = ({ id, courseId }) => {
const [shouldDisplay, setShouldDisplay] = useKeyedState(stateKeys.shouldDisplay, false);
const { graded } = useModel(modelKeys.units, id);
const { userNeedsIntegritySignature } = useModel(modelKeys.coursewareMeta, courseId);
React.useEffect(() => {
setShouldDisplay(userNeedsIntegritySignature && graded);
}, [setShouldDisplay, userNeedsIntegritySignature]);
return shouldDisplay;
};
export default useShouldDisplayHonorCode;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useModel } from '../../../../../generic/model-store';
import { modelKeys } from '../constants';
import useShouldDisplayHonorCode, { stateKeys } from './useShouldDisplayHonorCode';
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('../../../../../generic/model-store', () => ({
useModel: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
const props = {
id: 'test-id',
courseId: 'test-course-id',
};
const mockModels = (graded, userNeedsIntegritySignature) => {
useModel.mockImplementation((key) => (
(key === modelKeys.units) ? { graded } : { userNeedsIntegritySignature }
));
};
describe('useShouldDisplayHonorCode hook', () => {
beforeEach(() => {
jest.clearAllMocks();
mockModels(false, false);
state.mock();
});
describe('behavior', () => {
it('initializes shouldDisplay to false', () => {
useShouldDisplayHonorCode(props);
state.expectInitializedWith(stateKeys.shouldDisplay, false);
});
describe('effect - on userNeedsIntegritySignature', () => {
describe('graded and needs integrity signature', () => {
it('sets shouldDisplay(true)', () => {
mockModels(true, true);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(true);
});
});
describe('not graded', () => {
it('sets should not display', () => {
mockModels(true, false);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, false], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
});
});
describe('does not need integrity signature', () => {
it('sets should not display', () => {
mockModels(false, true);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
});
});
});
});
describe('output', () => {
it('returns shouldDisplay value from state', () => {
const testValue = 'test-value';
state.mockVal(stateKeys.shouldDisplay, testValue);
expect(useShouldDisplayHonorCode(props)).toEqual(testValue);
});
});
});

View File

@@ -0,0 +1,73 @@
import PropTypes from 'prop-types';
import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
import BookmarkButton from '../../bookmark/BookmarkButton';
import messages from '../messages';
import ContentIFrame from './ContentIFrame';
import UnitSuspense from './UnitSuspense';
import { modelKeys, views } from './constants';
import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
import { getIFrameUrl } from './urls';
const Unit = ({
courseId,
format,
onLoaded,
id,
}) => {
const { formatMessage } = useIntl();
const { authenticatedUser } = React.useContext(AppContext);
const examAccess = useExamAccess({ id });
const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id });
const unit = useModel(modelKeys.units, id);
const isProcessing = unit.bookmarkedUpdateState === 'loading';
const view = authenticatedUser ? views.student : views.public;
const iframeUrl = getIFrameUrl({
id,
view,
format,
examAccess,
});
return (
<div className="unit">
<h1 className="mb-0 h3">{unit.title}</h1>
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
<BookmarkButton
unitId={unit.id}
isBookmarked={unit.bookmarked}
isProcessing={isProcessing}
/>
<UnitSuspense {...{ courseId, id }} />
<ContentIFrame
elementId="unit-iframe"
id={id}
iframeUrl={iframeUrl}
loadingMessage={formatMessage(messages.loadingSequence)}
onLoaded={onLoaded}
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
title={unit.title}
/>
</div>
);
};
Unit.propTypes = {
courseId: PropTypes.string.isRequired,
format: PropTypes.string,
id: PropTypes.string.isRequired,
onLoaded: PropTypes.func,
};
Unit.defaultProps = {
format: null,
onLoaded: undefined,
};
export default Unit;

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
import { useModel } from '../../../../generic/model-store';
import BookmarkButton from '../../bookmark/BookmarkButton';
import UnitSuspense from './UnitSuspense';
import ContentIFrame from './ContentIFrame';
import Unit from '.';
import messages from '../messages';
import { getIFrameUrl } from './urls';
import { views } from './constants';
import * as hooks from './hooks';
jest.mock('./hooks', () => ({ useUnitData: jest.fn() }));
jest.mock('@edx/frontend-platform/i18n', () => {
const utils = jest.requireActual('@edx/react-unit-test-utils/dist');
return {
useIntl: () => ({ formatMessage: utils.formatMessage }),
defineMessages: m => m,
};
});
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('../../bookmark/BookmarkButton', () => 'BookmarkButton');
jest.mock('./ContentIFrame', () => 'ContentIFrame');
jest.mock('./UnitSuspense', () => 'UnitSuspense');
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../../../../generic/model-store', () => ({
useModel: jest.fn(),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn(v => v),
}));
jest.mock('./hooks', () => ({
useExamAccess: jest.fn(),
useShouldDisplayHonorCode: jest.fn(),
}));
jest.mock('./urls', () => ({
getIFrameUrl: jest.fn(),
}));
const props = {
courseId: 'test-course-id',
format: 'test-format',
onLoaded: jest.fn().mockName('props.onLoaded'),
id: 'test-props-id',
};
const context = { authenticatedUser: { test: 'user' } };
React.useContext.mockReturnValue(context);
const examAccess = {
accessToken: 'test-token',
blockAccess: false,
};
hooks.useExamAccess.mockReturnValue(examAccess);
hooks.useShouldDisplayHonorCode.mockReturnValue(false);
const unit = {
id: 'unit-id',
title: 'unit-title',
bookmarked: false,
bookmarkedUpdateState: 'pending',
};
useModel.mockReturnValue(unit);
let el;
describe('Unit component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<Unit {...props} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(hooks.useShouldDisplayHonorCode).toHaveBeenCalledWith({
courseId: props.courseId,
id: props.id,
});
});
});
describe('output', () => {
let component;
test('snapshot: not bookmarked, do not show content', () => {
el = shallow(<Unit {...props} />);
expect(el.snapshot).toMatchSnapshot();
});
describe('BookmarkButton props', () => {
const renderComponent = () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(BookmarkButton);
};
describe('not bookmarked, bookmark update loading', () => {
beforeEach(() => {
useModel.mockReturnValueOnce({ ...unit, bookmarkedUpdateState: 'loading' });
renderComponent();
});
test('snapshot', () => {
expect(component.snapshot).toMatchSnapshot();
});
test('props', () => {
expect(component.props.isBookmarked).toEqual(false);
expect(component.props.isProcessing).toEqual(true);
expect(component.props.unitId).toEqual(unit.id);
});
});
describe('bookmarked, bookmark update pending', () => {
beforeEach(() => {
useModel.mockReturnValueOnce({ ...unit, bookmarked: true });
renderComponent();
});
test('snapshot', () => {
expect(component.snapshot).toMatchSnapshot();
});
test('props', () => {
expect(component.props.isBookmarked).toEqual(true);
expect(component.props.isProcessing).toEqual(false);
expect(component.props.unitId).toEqual(unit.id);
});
});
});
test('UnitSuspense props', () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(UnitSuspense);
expect(component.props.courseId).toEqual(props.courseId);
expect(component.props.id).toEqual(props.id);
});
describe('ContentIFrame props', () => {
const testComponentProps = () => {
expect(component.props.elementId).toEqual('unit-iframe');
expect(component.props.id).toEqual(props.id);
expect(component.props.loadingMessage).toEqual(formatMessage(messages.loadingSequence));
expect(component.props.onLoaded).toEqual(props.onLoaded);
expect(component.props.title).toEqual(unit.title);
};
const loadComponent = () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(ContentIFrame);
};
describe('shouldShowContent', () => {
test('do not show content if displaying honor code', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(false);
});
test('do not show content if examAccess is blocked', () => {
hooks.useExamAccess.mockReturnValueOnce({ ...examAccess, blockAccess: true });
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(false);
});
test('show content if not displaying honor code or blocked by exam access', () => {
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(true);
});
});
describe('iframeUrl', () => {
test('loads iframe url with student view if authenticated user', () => {
loadComponent();
testComponentProps();
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
id: props.id,
view: views.student,
format: props.format,
examAccess,
}));
});
test('loads iframe url with public view if no authenticated user', () => {
React.useContext.mockReturnValueOnce({});
loadComponent();
testComponentProps();
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
id: props.id,
view: views.public,
format: props.format,
examAccess,
}));
});
});
});
});
});

View File

@@ -0,0 +1,28 @@
import { getConfig } from '@edx/frontend-platform';
import { stringify } from 'query-string';
export const iframeParams = {
show_title: 0,
show_bookmark: 0,
recheck_access: 1,
};
export const getIFrameUrl = ({
id,
view,
format,
examAccess,
}) => {
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
const params = stringify({
...iframeParams,
view,
...(format && { format }),
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
});
return `${xblockUrl}?${params}`;
};
export default {
getIFrameUrl,
};

View File

@@ -0,0 +1,42 @@
import { getConfig } from '@edx/frontend-platform';
import { stringify } from 'query-string';
import { getIFrameUrl, iframeParams } from './urls';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('query-string', () => ({
stringify: jest.fn((...args) => ({ stringify: args })),
}));
const config = { LMS_BASE_URL: 'test-lms-url' };
getConfig.mockReturnValue(config);
const props = {
id: 'test-id',
view: 'test-view',
format: 'test-format',
examAccess: { blockAccess: false, accessToken: 'test-access-token' },
};
describe('urls module', () => {
describe('getIFrameUrl', () => {
test('format provided, exam access and token available', () => {
const params = stringify({
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
});
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
test('no format provided, exam access blocked', () => {
const params = stringify({ ...iframeParams, view: props.view });
expect(getIFrameUrl({
id: props.id,
view: props.view,
examAccess: { blockAccess: true },
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
});
});

View File

@@ -1,9 +1,9 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import { Button } from '@edx/paragon';
import messages from './messages';
@@ -11,8 +11,9 @@ import messages from './messages';
const ContentLock = ({
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
}) => {
const navigate = useNavigate();
const handleClick = useCallback(() => {
history.push(`/course/${courseId}/${prereqId}`);
navigate(`/course/${courseId}/${prereqId}`);
}, [courseId, prereqId]);
return (

View File

@@ -1,10 +1,16 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import {
render, screen, fireEvent, initializeMockApp,
} from '../../../../setupTest';
import ContentLock from './ContentLock';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
describe('Content Lock', () => {
const mockData = {
courseId: 'test-course-id',
@@ -19,7 +25,7 @@ describe('Content Lock', () => {
});
it('displays sequence title along with lock icon', () => {
const { container } = render(<ContentLock {...mockData} />);
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true });
const lockIcon = container.querySelector('svg');
expect(lockIcon).toHaveClass('fa-lock');
@@ -28,16 +34,15 @@ describe('Content Lock', () => {
it('displays prerequisite name', () => {
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
render(<ContentLock {...mockData} />);
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
expect(screen.getByText(prereqText)).toBeInTheDocument();
});
it('handles click', () => {
history.push = jest.fn();
render(<ContentLock {...mockData} />);
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('button'));
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
});
});

View File

@@ -1,16 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { getConfig, history } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ActionRow, Alert, Button } from '@edx/paragon';
import { useNavigate } from 'react-router-dom';
import { useModel } from '../../../../generic/model-store';
import { saveIntegritySignature } from '../../../data';
import messages from './messages';
const HonorCode = ({ intl, courseId }) => {
const navigate = useNavigate();
const dispatch = useDispatch();
const {
isMasquerading,
@@ -20,7 +22,7 @@ const HonorCode = ({ intl, courseId }) => {
const siteName = getConfig().SITE_NAME;
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
const handleCancel = () => history.push(`/course/${courseId}/home`);
const handleCancel = () => navigate(`/course/${courseId}/home`);
const handleAgree = () => dispatch(
// If the request is made by a staff user masquerading as a specific learner,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { getConfig, history } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
@@ -9,12 +9,12 @@ import {
} from '../../../../setupTest';
import HonorCode from './HonorCode';
const mockNavigate = jest.fn();
initializeMockApp();
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
history: {
push: jest.fn(),
},
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
describe('Honor Code', () => {
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
it('cancel button links to course home ', async () => {
await setupStoreState();
render(<HonorCode {...mockData} />);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
});
it('calls to save integrity_signature when agreeing', async () => {
await setupStoreState({ username: authenticatedUser.username });
render(<HonorCode {...mockData} />);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
username: authenticatedUser.username,
},
);
render(<HonorCode {...mockData} />);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
username: 'otheruser',
},
);
render(<HonorCode {...mockData} />);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { breakpoints, Button, useWindowSize } from '@edx/paragon';
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
@@ -26,12 +27,13 @@ const SequenceNavigation = ({
sequenceId,
className,
onNavigate,
nextSequenceHandler,
previousSequenceHandler,
goToCourseExitPage,
nextHandler,
previousHandler,
}) => {
const sequence = useModel('sequences', sequenceId);
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
const {
courseId,
sequenceStatus,
@@ -63,27 +65,49 @@ const SequenceNavigation = ({
);
};
const renderPreviousButton = () => {
const disabled = isFirstUnit;
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
return (
<Button
variant="link"
className="previous-btn"
onClick={previousHandler}
disabled={disabled}
iconBefore={prevArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : previousLink}
>
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
</Button>
);
};
const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitActive;
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
return (
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
<Button
variant="link"
className="next-btn"
onClick={nextHandler}
disabled={disabled}
iconAfter={nextArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
>
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
</Button>
);
};
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
return sequenceStatus === LOADED && (
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
</Button>
{renderPreviousButton()}
{renderUnitButtons()}
{renderNextButton()}
@@ -97,9 +121,8 @@ SequenceNavigation.propTypes = {
unitId: PropTypes.string,
className: PropTypes.string,
onNavigate: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
nextHandler: PropTypes.func.isRequired,
previousHandler: PropTypes.func.isRequired,
};
SequenceNavigation.defaultProps = {

View File

@@ -25,22 +25,21 @@ describe('Sequence Navigation', () => {
mockData = {
unitId: unitBlocks[1].id,
sequenceId: courseware.sequenceId,
previousSequenceHandler: () => {},
previousHandler: () => {},
onNavigate: () => {},
nextSequenceHandler: () => {},
goToCourseExitPage: () => {},
nextHandler: () => {},
};
});
it('is empty while loading', async () => {
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore });
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
expect(container).toBeEmptyDOMElement();
});
it('renders empty div without unitId', () => {
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true });
expect(getByText(container, (content, element) => (
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
});
@@ -62,7 +61,7 @@ describe('Sequence Navigation', () => {
sequenceId: sequenceBlocks[0].id,
onNavigate: jest.fn(),
};
render(<SequenceNavigation {...testData} />, { store: testStore });
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true });
const unitButton = screen.getByTitle(unitBlocks[1].display_name);
fireEvent.click(unitButton);
@@ -75,27 +74,27 @@ describe('Sequence Navigation', () => {
it('renders correctly and handles unit button clicks', () => {
const onNavigate = jest.fn();
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
const unitButtons = screen.getAllByRole('button', { name: /\d+/ });
const unitButtons = screen.getAllByRole('link', { name: /\d+/ });
expect(unitButtons).toHaveLength(unitButtons.length);
unitButtons.forEach(button => fireEvent.click(button));
expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length);
});
it('has both navigation buttons enabled for a non-corner unit of the sequence', () => {
render(<SequenceNavigation {...mockData} />);
render(<SequenceNavigation {...mockData} />, { wrapWithRouter: true });
screen.getAllByRole('button', { name: /previous|next/i }).forEach(button => {
screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => {
expect(button).toBeEnabled();
});
});
it('has the "Previous" button disabled for the first unit of the sequence', () => {
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />);
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
});
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
@@ -107,10 +106,10 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
@@ -123,11 +122,11 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
});
it('displays complete course message instead of the "Next" button as needed', async () => {
@@ -144,22 +143,22 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
});
it('handles "Previous" and "Next" click', () => {
const previousSequenceHandler = jest.fn();
const nextSequenceHandler = jest.fn();
render(<SequenceNavigation {...mockData} {...{ previousSequenceHandler, nextSequenceHandler }} />);
const previousHandler = jest.fn();
const nextHandler = jest.fn();
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(previousSequenceHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(previousHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(nextSequenceHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(nextHandler).toHaveBeenCalledTimes(1);
});
});

View File

@@ -40,14 +40,17 @@ describe('Sequence Navigation Dropdown', () => {
unitBlocks.forEach((unit, index) => {
it(`marks unit ${index + 1} as active`, async () => {
const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
const { container } = render(
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
{ wrapWithRouter: true },
);
const dropdownToggle = container.querySelector('.dropdown-toggle');
await act(async () => {
await fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown-menu');
// Only the current unit should be marked as active.
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => {
if (button.textContent === unit.display_name) {
expect(button).toHaveClass('active');
} else {
@@ -59,14 +62,17 @@ describe('Sequence Navigation Dropdown', () => {
it('handles the clicks', () => {
const onNavigate = jest.fn();
const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
const { container } = render(
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
{ wrapWithRouter: true },
);
const dropdownToggle = container.querySelector('.dropdown-toggle');
act(() => {
fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown-menu');
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => fireEvent.click(button));
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button));
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
unitBlocks.forEach((unit, index) => {
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);

View File

@@ -41,16 +41,16 @@ describe('Sequence Navigation Tabs', () => {
it('renders unit buttons', () => {
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
render(<SequenceNavigationTabs {...mockData} />);
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
expect(screen.getAllByRole('button')).toHaveLength(unitBlocks.length);
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
});
it('renders unit buttons and dropdown button', async () => {
let container = null;
await act(async () => {
useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]);
const booyah = render(<SequenceNavigationTabs {...mockData} />);
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
container = booyah.container;
const dropdownToggle = container.querySelector('.dropdown-toggle');
@@ -60,8 +60,8 @@ describe('Sequence Navigation Tabs', () => {
await fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown');
const dropdownButtons = getAllByRole(dropdownMenu, 'button');
expect(dropdownButtons).toHaveLength(unitBlocks.length + 1);
const dropdownButtons = getAllByRole(dropdownMenu, 'link');
expect(dropdownButtons).toHaveLength(unitBlocks.length);
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
.toHaveClass('dropdown-toggle');
});

View File

@@ -1,6 +1,7 @@
import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { connect, useSelector } from 'react-redux';
import classNames from 'classnames';
import { Button } from '@edx/paragon';
@@ -20,6 +21,8 @@ const UnitButton = ({
className,
showTitle,
}) => {
const { courseId, sequenceId } = useSelector(state => state.courseware);
const handleClick = useCallback(() => {
onClick(unitId);
}, [onClick, unitId]);
@@ -33,6 +36,8 @@ const UnitButton = ({
variant="link"
onClick={handleClick}
title={title}
as={Link}
to={`/course/${courseId}/${sequenceId}/${unitId}`}
>
<UnitIcon type={contentType} />
{showTitle && <span className="unit-title">{title}</span>}

View File

@@ -32,13 +32,13 @@ describe('Unit Button', () => {
});
it('hides title by default', () => {
render(<UnitButton {...mockData} />);
expect(screen.getByRole('button')).not.toHaveTextContent(unit.display_name);
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
});
it('shows title', () => {
render(<UnitButton {...mockData} showTitle />);
expect(screen.getByRole('button')).toHaveTextContent(unit.display_name);
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
expect(screen.getByRole('link')).toHaveTextContent(unit.display_name);
});
it('does not show completion for non-completed unit', () => {
@@ -49,7 +49,7 @@ describe('Unit Button', () => {
});
it('shows completion for completed unit', () => {
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />);
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />, { wrapWithRouter: true });
const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(2);
expect(buttonIcons[1]).toHaveClass('fa-check');
@@ -70,7 +70,7 @@ describe('Unit Button', () => {
});
it('shows bookmark', () => {
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />);
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true });
const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(3);
expect(buttonIcons[2]).toHaveClass('fa-bookmark');
@@ -78,8 +78,8 @@ describe('Unit Button', () => {
it('handles the click', () => {
const onClick = jest.fn();
render(<UnitButton {...mockData} onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('link'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -20,14 +21,32 @@ const UnitNavigation = ({
unitId,
onClickPrevious,
onClickNext,
goToCourseExitPage,
}) => {
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
const { courseId } = useSelector(state => state.courseware);
const renderPreviousButton = () => {
const disabled = isFirstUnit;
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
return (
<Button
variant="outline-secondary"
className="previous-button mr-2 d-flex align-items-center justify-content-center"
disabled={disabled}
onClick={onClickPrevious}
as={disabled ? undefined : Link}
to={disabled ? undefined : previousLink}
>
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
{intl.formatMessage(messages.previousButton)}
</Button>
);
};
const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitActive;
const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight;
@@ -35,8 +54,10 @@ const UnitNavigation = ({
<Button
variant="outline-primary"
className="next-button d-flex align-items-center justify-content-center"
onClick={buttonOnClick}
onClick={onClickNext}
disabled={disabled}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
>
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
{buttonText}
@@ -46,18 +67,9 @@ const UnitNavigation = ({
);
};
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
return (
<div className="unit-navigation d-flex">
<Button
variant="outline-secondary"
className="previous-button mr-2 d-flex align-items-center justify-content-center"
disabled={isFirstUnit}
onClick={onClickPrevious}
>
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
{intl.formatMessage(messages.previousButton)}
</Button>
{renderPreviousButton()}
{renderNextButton()}
</div>
);
@@ -69,7 +81,6 @@ UnitNavigation.propTypes = {
unitId: PropTypes.string,
onClickPrevious: PropTypes.func.isRequired,
onClickNext: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
};
UnitNavigation.defaultProps = {

View File

@@ -22,7 +22,6 @@ describe('Unit Navigation', () => {
sequenceId: courseware.sequenceId,
onClickPrevious: () => {},
onClickNext: () => {},
goToCourseExitPage: () => {},
};
});
@@ -33,10 +32,10 @@ describe('Unit Navigation', () => {
unitId=""
onClickPrevious={() => {}}
onClickNext={() => {}}
/>);
/>, { wrapWithRouter: true });
// Only "Previous" and "Next" buttons should be rendered.
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.getAllByRole('link')).toHaveLength(2);
});
it('handles the clicks', () => {
@@ -45,32 +44,30 @@ describe('Unit Navigation', () => {
render(<UnitNavigation
{...mockData}
sequenceId=""
unitId=""
onClickPrevious={onClickPrevious}
onClickNext={onClickNext}
/>);
/>, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(onClickPrevious).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /next/i }));
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(onClickNext).toHaveBeenCalledTimes(1);
});
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
render(<UnitNavigation {...mockData} />);
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true });
screen.getAllByRole('button').forEach(button => {
screen.getAllByRole('link').forEach(button => {
expect(button).toBeEnabled();
});
});
it('has the "Previous" button disabled for the first unit in the sequence', () => {
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />);
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
});
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
@@ -82,10 +79,10 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
@@ -98,11 +95,11 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
});
it('displays complete course message instead of the "Next" button as needed', async () => {
@@ -119,10 +116,10 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore },
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
});
});

View File

@@ -7,6 +7,7 @@ import { sequenceIdsSelector } from '../../../data';
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
const sequenceIds = useSelector(sequenceIdsSelector);
const sequence = useModel('sequences', currentSequenceId);
const courseId = useSelector(state => state.courseware.courseId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
@@ -14,12 +15,43 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
return { isFirstUnit: false, isLastUnit: false };
}
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
const isFirstSequence = sequenceIndex === 0;
const isFirstUnitInSequence = unitIndex === 0;
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
const isLastUnit = isLastSequence && isLastUnitInSequence;
return { isFirstUnit, isLastUnit };
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
let nextLink;
if (isLastUnit) {
nextLink = `/course/${courseId}/course-end`;
} else {
const nextIndex = unitIndex + 1;
if (nextIndex < sequence.unitIds.length) {
const nextUnitId = sequence.unitIds[nextIndex];
nextLink = `/course/${courseId}/${currentSequenceId}/${nextUnitId}`;
} else if (nextSequenceId) {
nextLink = `/course/${courseId}/${nextSequenceId}/first`;
}
}
let previousLink;
const previousIndex = unitIndex - 1;
if (previousIndex >= 0) {
const previousUnitId = sequence.unitIds[previousIndex];
previousLink = `/course/${courseId}/${currentSequenceId}/${previousUnitId}`;
} else if (previousSequenceId) {
previousLink = `/course/${courseId}/${previousSequenceId}/last`;
}
return {
isFirstUnit, isLastUnit, nextLink, previousLink,
};
}

View File

@@ -5,8 +5,6 @@ import React, {
} from 'react';
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
import { getSessionStorage } from '../../../data/sessionStorage';
import { useModel } from '../../../generic/model-store';
import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
@@ -15,32 +13,18 @@ const SidebarProvider = ({
unitId,
children,
}) => {
const { verifiedMode } = useModel('courseHomeMeta', courseId);
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
const query = new URLSearchParams(window.location.search);
if (query.get('sidebar') === 'true') {
localStorage.setItem('showDiscussionSidebar', true);
}
const showDiscussionSidebar = localStorage.getItem('showDiscussionSidebar') !== 'false';
const showNotificationSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
? SIDEBARS.NOTIFICATIONS.ID
: null;
const initialSidebar = showDiscussionSidebar
? SIDEBARS.DISCUSSIONS.ID
: showNotificationSidebar;
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null;
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
useEffect(() => {
// As a one-off set initial sidebar if the verified mode data has just loaded
if (verifiedMode && currentSidebar === null && initialSidebar) {
setCurrentSidebar(initialSidebar);
}
setCurrentSidebar(SIDEBARS.DISCUSSIONS.ID);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialSidebar, verifiedMode]);
}, [unitId]);
const onNotificationSeen = useCallback(() => {
setNotificationStatus('inactive');
@@ -49,11 +33,6 @@ const SidebarProvider = ({
const toggleSidebar = useCallback((sidebarId) => {
// Switch to new sidebar or hide the current sidebar
if (currentSidebar === SIDEBARS.DISCUSSIONS.ID) {
localStorage.setItem('showDiscussionSidebar', false);
} else if (sidebarId === SIDEBARS.DISCUSSIONS.ID) {
localStorage.setItem('showDiscussionSidebar', true);
}
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
}, [currentSidebar]);

View File

@@ -3,7 +3,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { QuestionAnswer } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import React, { useContext, useEffect } from 'react';
import React, { useContext, useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useModel } from '../../../../../generic/model-store';
import { getCourseDiscussionTopics } from '../../../../data/thunks';
@@ -23,12 +23,16 @@ const DiscussionsTrigger = ({
courseId,
} = useContext(SidebarContext);
const dispatch = useDispatch();
const { tabs } = useModel('courseHomeMeta', courseId);
const topic = useModel('discussionTopics', unitId);
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
const edxProvider = useMemo(
() => tabs?.find(tab => tab.slug === 'discussion'),
[tabs],
);
useEffect(() => {
// Only fetch the topic data if the MFE is configured.
if (baseUrl) {
if (baseUrl && edxProvider) {
dispatch(getCourseDiscussionTopics(courseId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -57,4 +57,5 @@ Factory.define('courseMetadata')
related_programs: null,
user_needs_integrity_signature: false,
recommendations: null,
learning_assistant_enabled: null,
});

View File

@@ -122,6 +122,7 @@ function normalizeMetadata(metadata) {
relatedPrograms: camelCaseObject(data.related_programs),
userNeedsIntegritySignature: data.user_needs_integrity_signature,
canAccessProctoredExams: data.can_access_proctored_exams,
learningAssistantEnabled: data.learning_assistant_enabled,
};
}

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 {
getCourseMetadata,
@@ -19,8 +19,8 @@ import {
const {
somethingLike: like, term, boolean, string, eachLike, integer,
} = Matchers;
const provider = new Pact({
} = MatchersV3;
const provider = new PactV3({
consumer: 'frontend-app-learning',
provider: 'lms',
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
@@ -33,16 +33,11 @@ const provider = new Pact({
describe('Courseware 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 get a learning sequence outline is made', () => {
it('returns a normalized outline', async () => {
const normalizedOutline = {
@@ -233,6 +228,7 @@ describe('Courseware Service', () => {
linkedinAddToProfileUrl: null,
relatedPrograms: null,
userNeedsIntegritySignature: false,
learningAssistantEnabled: false,
};
setTimeout(() => {
provider.addInteraction({
@@ -338,6 +334,7 @@ describe('Courseware Service', () => {
verification_status: string('none'),
linkedin_add_to_profile_url: null,
user_needs_integrity_signature: boolean(false),
learning_assistant_enabled: boolean(false),
},
},
});

22
src/courseware/utils.jsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
const withParamsAndNavigation = WrappedComponent => {
const WithParamsNavigationComponent = props => {
const { courseId, sequenceId, unitId } = useParams();
const navigate = useNavigate();
return (
<WrappedComponent
routeCourseId={courseId}
routeSequenceId={sequenceId}
routeUnitId={unitId}
navigate={navigate}
{...props}
/>
);
};
return WithParamsNavigationComponent;
};
export default withParamsAndNavigation;

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