Compare commits
80 Commits
open-relea
...
mashal-m/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a071eda78e | ||
|
|
29fa6b3858 | ||
|
|
119c0ff4fb | ||
|
|
00e7680c20 | ||
|
|
cbb419c256 | ||
|
|
12205de132 | ||
|
|
62465ec956 | ||
|
|
165097d061 | ||
|
|
570cdb4b2a | ||
|
|
391ea08b20 | ||
|
|
5604def491 | ||
|
|
b788b969c3 | ||
|
|
b7a3d5640a | ||
|
|
3a21d8c807 | ||
|
|
81442bebe9 | ||
|
|
168ed1e184 | ||
|
|
c8e32c3f46 | ||
|
|
51dd90741b | ||
|
|
f58d6d6d25 | ||
|
|
81a49bd755 | ||
|
|
2ae033160f | ||
|
|
32bd3190a6 | ||
|
|
645ac2cb5f | ||
|
|
ee80b24cba | ||
|
|
ee1d816cc8 | ||
|
|
e8ac2ffc7e | ||
|
|
62d3e95cc8 | ||
|
|
ce6771d7cc | ||
|
|
1dcde821b4 | ||
|
|
694e3ed6d5 | ||
|
|
ba843622c2 | ||
|
|
2d29827e6b | ||
|
|
2b9b3db5d3 | ||
|
|
2e90e214b4 | ||
|
|
ea2d7ed839 | ||
|
|
5ee61904d5 | ||
|
|
6232b0cb98 | ||
|
|
09542338a2 | ||
|
|
c3d345e642 | ||
|
|
ec2bf60345 | ||
|
|
b0c71e5291 | ||
|
|
dcd6847254 | ||
|
|
d2df9241c3 | ||
|
|
1871e491a7 | ||
|
|
03543c0af1 | ||
|
|
0c49658314 | ||
|
|
2a1173584e | ||
|
|
398330fa07 | ||
|
|
f92fc8c3a5 | ||
|
|
5e072949d6 | ||
|
|
2d132f114c | ||
|
|
c73ef26d8e | ||
|
|
97ca7fe6aa | ||
|
|
e95a59c6c8 | ||
|
|
5f9c441cd2 | ||
|
|
2e641ac6c9 | ||
|
|
22937918ab | ||
|
|
714f5d452c | ||
|
|
8ac9745261 | ||
|
|
340580cb41 | ||
|
|
5a99ca5c91 | ||
|
|
9943df49e4 | ||
|
|
855474d406 | ||
|
|
a78496a3f6 | ||
|
|
79b65dadca | ||
|
|
fc8f5d43e8 | ||
|
|
6232f40a74 | ||
|
|
bc0ff1ce65 | ||
|
|
5997b29cee | ||
|
|
d2de0632cd | ||
|
|
922cc2187a | ||
|
|
d9539796b5 | ||
|
|
e0acb501eb | ||
|
|
a03ffe2724 | ||
|
|
cbdf7ce064 | ||
|
|
7184e85b2b | ||
|
|
b5321d01e4 | ||
|
|
6c8ab1a4c9 | ||
|
|
01f9d8f50b | ||
|
|
764befd4bd |
1
.env
1
.env
@@ -46,3 +46,4 @@ TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -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
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
|
||||
18
Makefile
18
Makefile
@@ -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:
|
||||
|
||||
121
README.rst
121
README.rst
@@ -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.
|
||||
|
||||
@@ -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
14092
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -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",
|
||||
|
||||
@@ -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="Don’t 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
33
src/constants.js
Normal 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',
|
||||
};
|
||||
95
src/course-home/courseware-search/CoursewareSearch.jsx
Normal file
95
src/course-home/courseware-search/CoursewareSearch.jsx
Normal 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);
|
||||
84
src/course-home/courseware-search/CoursewareSearch.test.jsx
Normal file
84
src/course-home/courseware-search/CoursewareSearch.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
38
src/course-home/courseware-search/CoursewareSearchToggle.jsx
Normal file
38
src/course-home/courseware-search/CoursewareSearchToggle.jsx
Normal 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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
45
src/course-home/courseware-search/courseware-search.scss
Normal file
45
src/course-home/courseware-search/courseware-search.scss
Normal 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;
|
||||
}
|
||||
71
src/course-home/courseware-search/hooks.js
Normal file
71
src/course-home/courseware-search/hooks.js
Normal 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');
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
187
src/course-home/courseware-search/hooks.test.jsx
Normal file
187
src/course-home/courseware-search/hooks.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/course-home/courseware-search/index.js
Normal file
3
src/course-home/courseware-search/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CoursewareSearchToggle } from './CoursewareSearchToggle';
|
||||
export { default as CoursewareSearch } from './CoursewareSearch';
|
||||
21
src/course-home/courseware-search/messages.js
Normal file
21
src/course-home/courseware-search/messages.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
45
src/courseware/RedirectPage.jsx
Normal file
45
src/courseware/RedirectPage.jsx
Normal 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;
|
||||
69
src/courseware/RedirectPage.test.jsx
Normal file
69
src/courseware/RedirectPage.test.jsx
Normal 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`);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
76
src/courseware/course/chat/Chat.jsx
Normal file
76
src/courseware/course/chat/Chat.jsx
Normal 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);
|
||||
157
src/courseware/course/chat/Chat.test.jsx
Normal file
157
src/courseware/course/chat/Chat.test.jsx
Normal 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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
1
src/courseware/course/chat/index.js
Normal file
1
src/courseware/course/chat/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Chat';
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
114
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal file
114
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal 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;
|
||||
175
src/courseware/course/sequence/Unit/ContentIFrame.test.jsx
Normal file
175
src/courseware/course/sequence/Unit/ContentIFrame.test.jsx
Normal 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 }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
50
src/courseware/course/sequence/Unit/UnitSuspense.jsx
Normal file
50
src/courseware/course/sequence/Unit/UnitSuspense.jsx
Normal 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;
|
||||
106
src/courseware/course/sequence/Unit/UnitSuspense.test.jsx
Normal file
106
src/courseware/course/sequence/Unit/UnitSuspense.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
26
src/courseware/course/sequence/Unit/constants.js
Normal file
26
src/courseware/course/sequence/Unit/constants.js
Normal 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,
|
||||
});
|
||||
5
src/courseware/course/sequence/Unit/hooks/index.js
Normal file
5
src/courseware/course/sequence/Unit/hooks/index.js
Normal 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';
|
||||
37
src/courseware/course/sequence/Unit/hooks/useExamAccess.js
Normal file
37
src/courseware/course/sequence/Unit/hooks/useExamAccess.js
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
116
src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js
Normal file
116
src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
src/courseware/course/sequence/Unit/index.jsx
Normal file
73
src/courseware/course/sequence/Unit/index.jsx
Normal 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;
|
||||
191
src/courseware/course/sequence/Unit/index.test.jsx
Normal file
191
src/courseware/course/sequence/Unit/index.test.jsx
Normal 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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
28
src/courseware/course/sequence/Unit/urls.js
Normal file
28
src/courseware/course/sequence/Unit/urls.js
Normal 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,
|
||||
};
|
||||
42
src/courseware/course/sequence/Unit/urls.test.js
Normal file
42
src/courseware/course/sequence/Unit/urls.test.js
Normal 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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,4 +57,5 @@ Factory.define('courseMetadata')
|
||||
related_programs: null,
|
||||
user_needs_integrity_signature: false,
|
||||
recommendations: null,
|
||||
learning_assistant_enabled: null,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
22
src/courseware/utils.jsx
Normal 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
Reference in New Issue
Block a user