Compare commits
89 Commits
bw/hackath
...
alangsto/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a5687bfe3 | ||
|
|
c38d69f9db | ||
|
|
18103bcf54 | ||
|
|
9698c4d4de | ||
|
|
a70a26f2e5 | ||
|
|
308f03cf3a | ||
|
|
9a0cdc06c9 | ||
|
|
a64f0e0406 | ||
|
|
ce24a58c99 | ||
|
|
c257048d29 | ||
|
|
7c9211073f | ||
|
|
040f1cb55b | ||
|
|
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
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
7
.github/workflows/validate.yml
vendored
7
.github/workflows/validate.yml
vendored
@@ -9,14 +9,13 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
2
.gitignore
vendored
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'
|
||||
});
|
||||
|
||||
44757
package-lock.json
generated
44757
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -29,52 +29,56 @@
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@edx/frontend-component-footer": "11.6.3",
|
||||
"@edx/frontend-component-header": "3.6.4",
|
||||
"@edx/frontend-lib-special-exams": "2.10.0",
|
||||
"@edx/frontend-platform": "3.4.1",
|
||||
"@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.17.0",
|
||||
"@edx/frontend-lib-special-exams": "2.23.3",
|
||||
"@edx/frontend-platform": "5.5.2",
|
||||
"@edx/paragon": "20.46.0",
|
||||
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.1.18",
|
||||
"@popperjs/core": "2.11.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",
|
||||
"joi": "^17.11.0",
|
||||
"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.4.15",
|
||||
"@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",
|
||||
"es-check": "6.2.1",
|
||||
"husky": "7.0.4",
|
||||
"jest": "27.5.1",
|
||||
"jest": "29.5.0",
|
||||
"rosie": "2.1.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',
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Tabs, Tab } from '@edx/paragon';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
import CoursewareSearchResults from './CoursewareSearchResults';
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const noFilterKey = 'none';
|
||||
const noFilterLabel = 'All content';
|
||||
|
||||
export const filteredResultsBySelection = ({ key = noFilterKey, results = [] }) => (
|
||||
key === noFilterKey ? results : results.filter(({ type }) => type === key)
|
||||
);
|
||||
|
||||
export const CoursewareSearchResultsFilter = ({ intl }) => {
|
||||
const { courseId } = useParams();
|
||||
const lastSearch = useModel('contentSearchResults', courseId);
|
||||
|
||||
if (!lastSearch || !lastSearch?.results?.length) { return null; }
|
||||
|
||||
const { total, results } = lastSearch;
|
||||
|
||||
const filters = [
|
||||
{
|
||||
key: noFilterKey,
|
||||
label: noFilterLabel,
|
||||
count: total,
|
||||
},
|
||||
...lastSearch.filters,
|
||||
];
|
||||
|
||||
const getFilterTitle = (key, fallback) => {
|
||||
const msg = messages[`filter:${key}`];
|
||||
if (!msg) { return fallback; }
|
||||
return intl.formatMessage(msg);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
id="courseware-search-results-tabs"
|
||||
data-testid="courseware-search-results-tabs"
|
||||
variant="tabs"
|
||||
defaultActiveKey={noFilterKey}
|
||||
>
|
||||
{filters.map(({ key, label }) => (
|
||||
<Tab key={key} eventKey={key} title={getFilterTitle(key, label)}>
|
||||
<CoursewareSearchResults
|
||||
results={filteredResultsBySelection({ key, results })}
|
||||
/>
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearchResultsFilter.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CoursewareSearchResultsFilter);
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../setupTest';
|
||||
import { CoursewareSearchResultsFilter, filteredResultsBySelection } from './CoursewareResultsFilter';
|
||||
import initializeStore from '../../store';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
jest.mock('../../generic/model-store', () => ({
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockResults = [
|
||||
{
|
||||
id: 'video-1', type: 'video', title: 'video_title', score: 3, contentHits: 1, url: '/video-1', location: ['path1', 'path2'],
|
||||
},
|
||||
{
|
||||
id: 'video-2', type: 'video', title: 'video_title2', score: 2, contentHits: 1, url: '/video-2', location: ['path1', 'path2'],
|
||||
},
|
||||
{
|
||||
id: 'document-1', type: 'document', title: 'document_title', score: 3, contentHits: 1, url: '/document-1', location: ['path1', 'path2'],
|
||||
},
|
||||
{
|
||||
id: 'text-1', type: 'text', title: 'text_title1', score: 3, contentHits: 1, url: '/text-1', location: ['path1', 'path2'],
|
||||
},
|
||||
{
|
||||
id: 'text-2', type: 'text', title: 'text_title2', score: 2, contentHits: 1, url: '/text-2', location: ['path1', 'path2'],
|
||||
},
|
||||
{
|
||||
id: 'text-3', type: 'text', title: 'text_title3', score: 1, contentHits: 1, url: '/text-3', location: ['path1', 'path2'],
|
||||
},
|
||||
];
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
|
||||
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const pathname = `/course/${decodedCourseId}/${decodedSequenceId}/${decodedUnitId}`;
|
||||
|
||||
const intl = {
|
||||
formatMessage: (message) => message?.defaultMessage || '',
|
||||
};
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const store = initializeStore();
|
||||
history.push(pathname);
|
||||
const { container } = render(
|
||||
<AppProvider store={store}>
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/:sequenceId/:unitId" element={<CoursewareSearchResultsFilter intl={intl} {...props} />} />
|
||||
</Routes>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchResultsFilter', () => {
|
||||
beforeAll(initializeMockApp);
|
||||
|
||||
describe('filteredResultsBySelection', () => {
|
||||
it('returns a no values array when no results are provided', () => {
|
||||
const results = filteredResultsBySelection({ results: [] });
|
||||
|
||||
expect(results.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('returns all values when no key value is provided', () => {
|
||||
const results = filteredResultsBySelection({ results: mockResults });
|
||||
|
||||
expect(results.length).toEqual(mockResults.length);
|
||||
});
|
||||
|
||||
it('returns only "video"-typed elements when the key value "video" is given', () => {
|
||||
const results = filteredResultsBySelection({ key: 'video', results: mockResults });
|
||||
|
||||
expect(results.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('returns only "course_outline"-typed elements when the key value "document" is given', () => {
|
||||
const results = filteredResultsBySelection({ key: 'document', results: mockResults });
|
||||
|
||||
expect(results.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('returns only "text"-typed elements when the key value "text" is given', () => {
|
||||
const results = filteredResultsBySelection({ key: 'text', results: mockResults });
|
||||
|
||||
expect(results.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('</CoursewareSearchResultsFilter />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render', async () => {
|
||||
useModel.mockReturnValue({
|
||||
total: 6,
|
||||
results: mockResults,
|
||||
filters: [],
|
||||
});
|
||||
|
||||
await renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/All content/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
146
src/course-home/courseware-search/CoursewareSearch.jsx
Normal file
146
src/course-home/courseware-search/CoursewareSearch.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert, Button, Icon, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Close,
|
||||
} from '@edx/paragon/icons';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { useElementBoundingBox, useLockScroll } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
import CoursewareSearchForm from './CoursewareSearchForm';
|
||||
import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter';
|
||||
import { updateModel, useModel } from '../../generic/model-store';
|
||||
import { searchCourseContent } from '../data/thunks';
|
||||
|
||||
const CoursewareSearch = ({ intl, ...sectionProps }) => {
|
||||
const { courseId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
loading,
|
||||
searchKeyword: lastSearchKeyword,
|
||||
errors,
|
||||
total,
|
||||
} = useModel('contentSearchResults', courseId);
|
||||
const [searchKeyword, setSearchKeyword] = useState(lastSearchKeyword);
|
||||
|
||||
useLockScroll();
|
||||
|
||||
const info = useElementBoundingBox('courseTabsNavigation');
|
||||
const top = info ? `${Math.floor(info.top)}px` : 0;
|
||||
|
||||
const clearSearch = () => {
|
||||
dispatch(updateModel({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: courseId,
|
||||
searchKeyword: '',
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!searchKeyword) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
|
||||
...eventProperties,
|
||||
event_type: 'searchKeyword',
|
||||
keyword: searchKeyword,
|
||||
});
|
||||
|
||||
dispatch(searchCourseContent(courseId, searchKeyword));
|
||||
};
|
||||
|
||||
const handleOnChange = (value) => {
|
||||
if (value === searchKeyword) { return; }
|
||||
|
||||
setSearchKeyword(value);
|
||||
|
||||
if (!value) {
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
|
||||
let status = 'idle';
|
||||
if (loading) {
|
||||
status = 'loading';
|
||||
} else if (errors) {
|
||||
status = 'error';
|
||||
} else if (lastSearchKeyword) {
|
||||
status = 'results';
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-section" {...sectionProps}>
|
||||
<div className="courseware-search__close">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="p-1"
|
||||
aria-label={intl.formatMessage(messages.searchCloseAction)}
|
||||
onClick={() => dispatch(setShowSearch(false))}
|
||||
data-testid="courseware-search-close-button"
|
||||
><Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="courseware-search__outer-content">
|
||||
<div className="courseware-search__content">
|
||||
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
|
||||
<CoursewareSearchForm
|
||||
searchTerm={searchKeyword}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleOnChange}
|
||||
placeholder={intl.formatMessage(messages.searchBarPlaceholderText)}
|
||||
/>
|
||||
{status === 'loading' ? (
|
||||
<div className="courseware-search__spinner">
|
||||
<Spinner animation="border" variant="light" screenReaderText={intl.formatMessage(messages.loading)} />
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'error' && (
|
||||
<Alert className="mt-4" variant="danger">
|
||||
{intl.formatMessage(messages.searchResultsError)}
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'results' ? (
|
||||
<>
|
||||
<div className="courseware-search__results-summary">{total > 0
|
||||
? (
|
||||
intl.formatMessage(
|
||||
total === 1
|
||||
? messages.searchResultsSingular
|
||||
: messages.searchResultsPlural,
|
||||
{ total, keyword: lastSearchKeyword },
|
||||
)
|
||||
) : intl.formatMessage(messages.searchResultsNone)}
|
||||
</div>
|
||||
<CoursewareSearchResultsFilterContainer />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearch.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CoursewareSearch);
|
||||
106
src/course-home/courseware-search/CoursewareSearch.test.jsx
Normal file
106
src/course-home/courseware-search/CoursewareSearch.test.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
} from '../../setupTest';
|
||||
import { CoursewareSearch } from './index';
|
||||
import { useElementBoundingBox, useLockScroll } from './hooks';
|
||||
import initializeStore from '../../store';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
jest.mock('./hooks');
|
||||
jest.mock('../../generic/model-store', () => ({
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
|
||||
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const pathname = `/course/${decodedCourseId}/${decodedSequenceId}/${decodedUnitId}`;
|
||||
|
||||
const tabsTopPosition = 128;
|
||||
|
||||
const defaultProps = {
|
||||
org: 'edX',
|
||||
loading: false,
|
||||
searchKeyword: '',
|
||||
errors: undefined,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const intl = {
|
||||
formatMessage: (message) => message?.defaultMessage || '',
|
||||
};
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const store = initializeStore();
|
||||
history.push(pathname);
|
||||
const { container } = render(
|
||||
<AppProvider store={store}>
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/:sequenceId/:unitId" element={<CoursewareSearch intl={intl} {...props} />} />
|
||||
</Routes>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
const mockModels = ((props = defaultProps) => {
|
||||
useModel.mockReturnValue(props);
|
||||
});
|
||||
|
||||
describe('CoursewareSearch', () => {
|
||||
beforeAll(initializeMockApp);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when rendering normally', () => {
|
||||
beforeAll(() => {
|
||||
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
|
||||
});
|
||||
|
||||
it('Should use useElementBoundingBox() and useLockScroll() hooks', () => {
|
||||
mockModels();
|
||||
renderComponent();
|
||||
|
||||
expect(useElementBoundingBox).toBeCalledTimes(1);
|
||||
expect(useLockScroll).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
|
||||
mockModels();
|
||||
renderComponent();
|
||||
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when CourseTabsNavigation is not present', () => {
|
||||
it('Should use "--modal-top-position: 0" if nce element is not present', () => {
|
||||
useElementBoundingBox.mockImplementation(() => undefined);
|
||||
|
||||
mockModels();
|
||||
renderComponent();
|
||||
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passing extra props', () => {
|
||||
it('Should pass on extra props to section element', () => {
|
||||
mockModels();
|
||||
renderComponent({ foo: 'bar' });
|
||||
|
||||
const section = screen.getByTestId('courseware-search-section');
|
||||
expect(section).toHaveAttribute('foo', 'bar');
|
||||
});
|
||||
});
|
||||
});
|
||||
15
src/course-home/courseware-search/CoursewareSearchEmpty.jsx
Normal file
15
src/course-home/courseware-search/CoursewareSearchEmpty.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const CoursewareSearchEmpty = ({ intl }) => (
|
||||
<div className="courseware-search-results">
|
||||
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
CoursewareSearchEmpty.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CoursewareSearchEmpty);
|
||||
41
src/course-home/courseware-search/CoursewareSearchForm.jsx
Normal file
41
src/course-home/courseware-search/CoursewareSearchForm.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { SearchField } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const CoursewareSearchForm = ({
|
||||
searchTerm,
|
||||
onSubmit,
|
||||
onChange,
|
||||
placeholder,
|
||||
}) => (
|
||||
<SearchField.Advanced
|
||||
value={searchTerm}
|
||||
onSubmit={onSubmit}
|
||||
onChange={onChange}
|
||||
submitButtonLocation="external"
|
||||
className="courseware-search-form"
|
||||
>
|
||||
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
|
||||
<SearchField.Label />
|
||||
<SearchField.Input placeholder={placeholder} autoFocus />
|
||||
<SearchField.ClearButton />
|
||||
</div>
|
||||
<SearchField.SubmitButton submitButtonLocation="external" />
|
||||
</SearchField.Advanced>
|
||||
);
|
||||
|
||||
CoursewareSearchForm.propTypes = {
|
||||
searchTerm: PropTypes.string,
|
||||
onSubmit: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
|
||||
CoursewareSearchForm.defaultProps = {
|
||||
searchTerm: undefined,
|
||||
onSubmit: undefined,
|
||||
onChange: undefined,
|
||||
placeholder: undefined,
|
||||
};
|
||||
|
||||
export default CoursewareSearchForm;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from '../../setupTest';
|
||||
import CoursewareSearchForm from './CoursewareSearchForm';
|
||||
|
||||
function renderComponent(placeholder, onSubmit, onChange) {
|
||||
const { container } = render(<CoursewareSearchForm
|
||||
placeholder={placeholder}
|
||||
onSubmit={onSubmit}
|
||||
onChange={onChange}
|
||||
/>);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchToggle', () => {
|
||||
const placeholderText = 'Search for courseware';
|
||||
let onSubmitHandlerMock;
|
||||
let onChangeHandlerMock;
|
||||
|
||||
beforeAll(async () => {
|
||||
onChangeHandlerMock = jest.fn();
|
||||
onSubmitHandlerMock = jest.fn();
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
it('should render', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('courseware-search-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChange handler when input changes', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(() => {
|
||||
const element = screen.queryByPlaceholderText(placeholderText);
|
||||
fireEvent.change(element, { target: { value: 'test' } });
|
||||
expect(onChangeHandlerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSubmit handler when submit is clicked', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(() => {
|
||||
const element = screen.queryAllByText('Search')[0];
|
||||
fireEvent.click(element);
|
||||
expect(onSubmitHandlerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Folder, TextFields, VideoCamera, Article,
|
||||
} from '@edx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
|
||||
|
||||
const iconTypeMapping = {
|
||||
document: Folder,
|
||||
text: TextFields,
|
||||
video: VideoCamera,
|
||||
};
|
||||
|
||||
const defaultIcon = Article;
|
||||
|
||||
const CoursewareSearchResults = ({ results }) => {
|
||||
if (!results?.length) {
|
||||
return <CoursewareSearchEmpty />;
|
||||
}
|
||||
|
||||
const baseUrl = `${getConfig().LMS_BASE_URL}`;
|
||||
|
||||
return (
|
||||
<div className="courseware-search-results" data-testid="search-results">
|
||||
{results.map(({
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
location,
|
||||
url,
|
||||
contentHits,
|
||||
}) => {
|
||||
const key = type.toLowerCase();
|
||||
const icon = iconTypeMapping[key] || defaultIcon;
|
||||
|
||||
const isExternal = !url.startsWith('/');
|
||||
|
||||
const linkProps = isExternal ? {
|
||||
href: url,
|
||||
target: '_blank',
|
||||
rel: 'nofollow',
|
||||
} : { href: `${baseUrl}${url}` };
|
||||
|
||||
return (
|
||||
<a key={id} className="courseware-search-results__item" {...linkProps}>
|
||||
<div className="courseware-search-results__icon"><Icon src={icon} /></div>
|
||||
<div className="courseware-search-results__info">
|
||||
<div className="courseware-search-results__title">
|
||||
<span>{title}</span>
|
||||
{contentHits ? (<em>{contentHits}</em>) : null }
|
||||
</div>
|
||||
{location?.length ? (
|
||||
<ul className="courseware-search-results__breadcrumbs">
|
||||
{
|
||||
// This ignore is necessary because the breadcrumb texts might have duplicates.
|
||||
// The breadcrumbs are not expected to change.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
location.map((breadcrumb, i) => (<li key={`${i}:${breadcrumb}`}><div>{breadcrumb}</div></li>))
|
||||
}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearchResults.propTypes = {
|
||||
results: PropTypes.arrayOf(PropTypes.objectOf({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
location: PropTypes.arrayOf(PropTypes.string),
|
||||
url: PropTypes.string,
|
||||
contentHits: PropTypes.number,
|
||||
score: PropTypes.number,
|
||||
})),
|
||||
};
|
||||
|
||||
CoursewareSearchResults.defaultProps = {
|
||||
results: [],
|
||||
};
|
||||
|
||||
export default CoursewareSearchResults;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
} from '../../setupTest';
|
||||
import CoursewareSearchResults from './CoursewareSearchResults';
|
||||
import messages from './messages';
|
||||
// import mockedData from './test-data/mockedResults'; // TODO: Update this test.
|
||||
|
||||
jest.mock('react-redux');
|
||||
|
||||
function renderComponent({ results }) {
|
||||
const { container } = render(<CoursewareSearchResults results={results} />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchResults', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
describe('when an empty array is provided', () => {
|
||||
beforeEach(() => { renderComponent({ results: [] }); });
|
||||
|
||||
it('should render a "no results found" message.', () => {
|
||||
expect(screen.getByTestId('no-results').textContent).toBe(messages.searchResultsNone.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
/* describe('when list of results is provided', () => {
|
||||
beforeEach(() => { renderComponent({ results: mockedData }); });
|
||||
|
||||
it('should match the snapshot', () => {
|
||||
expect(screen.getByTestId('search-results')).toMatchSnapshot();
|
||||
});
|
||||
}); */
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
|
||||
Object {
|
||||
"filters": Array [
|
||||
Object {
|
||||
"count": 7,
|
||||
"key": "capa",
|
||||
"label": "CAPA",
|
||||
},
|
||||
Object {
|
||||
"count": 2,
|
||||
"key": "sequence",
|
||||
"label": "Sequence",
|
||||
},
|
||||
Object {
|
||||
"count": 9,
|
||||
"key": "text",
|
||||
"label": "Text",
|
||||
},
|
||||
Object {
|
||||
"count": 2,
|
||||
"key": "video",
|
||||
"label": "Video",
|
||||
},
|
||||
],
|
||||
"maxScore": 3.4545178,
|
||||
"ms": 5,
|
||||
"results": Array [
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"location": Array [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
],
|
||||
"score": 3.4545178,
|
||||
"title": "Demo Course Overview",
|
||||
"type": "sequence",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course",
|
||||
],
|
||||
"score": 3.4545178,
|
||||
"title": "Passing a Course",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course",
|
||||
],
|
||||
"score": 3.4545178,
|
||||
"title": "Passing a Course",
|
||||
"type": "sequence",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Text input",
|
||||
],
|
||||
"score": 1.5874016,
|
||||
"title": "Text Input",
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Pointing on a Picture",
|
||||
],
|
||||
"score": 1.5499392,
|
||||
"title": "Pointing on a Picture",
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Getting Answers",
|
||||
],
|
||||
"score": 1.5003732,
|
||||
"title": "Getting Answers",
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
"location": Array [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences",
|
||||
],
|
||||
"score": 1.4792063,
|
||||
"title": "Welcome!",
|
||||
"type": "video",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Multiple Choice Questions",
|
||||
],
|
||||
"score": 1.4341705,
|
||||
"title": "Multiple Choice Questions",
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Numerical Input",
|
||||
],
|
||||
"score": 1.2987298,
|
||||
"title": "Numerical Input",
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Video Presentation Styles",
|
||||
],
|
||||
"score": 1.1870136,
|
||||
"title": "Connecting a Circuit and a Circuit Diagram",
|
||||
"type": "video",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
"location": Array [
|
||||
"Example Week 2: Get Interactive",
|
||||
"Homework - Labs and Demos",
|
||||
"Code Grader",
|
||||
],
|
||||
"score": 1.0107487,
|
||||
"title": "CAPA",
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Interactive Questions",
|
||||
],
|
||||
"score": 0.96387196,
|
||||
"title": "Interactive Questions",
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
"location": Array [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences",
|
||||
],
|
||||
"score": 0.8844358,
|
||||
"title": "Blank HTML Page",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
"location": Array [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Discussion Forums",
|
||||
],
|
||||
"score": 0.8803684,
|
||||
"title": "Discussion Forums",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Overall Grade Performance",
|
||||
],
|
||||
"score": 0.87981963,
|
||||
"title": "Overall Grade",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
"location": Array [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
],
|
||||
"score": 0.84284115,
|
||||
"title": "Blank HTML Page",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
"location": Array [
|
||||
"Example Week 3: Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
"Homework - Find Your Study Buddy",
|
||||
],
|
||||
"score": 0.84284115,
|
||||
"title": "Find Your Study Buddy",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
"location": Array [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Be Social",
|
||||
],
|
||||
"score": 0.84210813,
|
||||
"title": "Be Social",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"EdX Exams",
|
||||
],
|
||||
"score": 0.8306555,
|
||||
"title": "EdX Exams",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
},
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"When Are Your Exams? ",
|
||||
],
|
||||
"score": 0.82610154,
|
||||
"title": "When Are Your Exams? ",
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
},
|
||||
],
|
||||
"total": 29,
|
||||
}
|
||||
`;
|
||||
152
src/course-home/courseware-search/courseware-search.scss
Normal file
152
src/course-home/courseware-search/courseware-search.scss
Normal file
@@ -0,0 +1,152 @@
|
||||
.courseware-search {
|
||||
background: white;
|
||||
position: fixed;
|
||||
top: var(--modal-top-position, 0);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-top: 1px solid $light-300;
|
||||
z-index: 200;
|
||||
|
||||
&__close {
|
||||
position: absolute !important; // For some reason it gets overridden
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__outer-content {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 2rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
max-width: 42rem;
|
||||
margin: auto;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__results-summary {
|
||||
font-size: .9rem;
|
||||
color: $gray-400;
|
||||
padding: 1rem 0 .5rem;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 20vh;
|
||||
}
|
||||
}
|
||||
|
||||
.courseware-search-results {
|
||||
margin-top: 1.5rem;
|
||||
|
||||
&__empty {
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: block;
|
||||
padding: .75rem 1rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: $light-300;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid $light-300;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
padding: 0.375rem 0 0 0.375rem;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 2.5;
|
||||
font-size: 0.875rem;
|
||||
color: $black;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
em {
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
min-width: 1.25rem;
|
||||
line-height: 1rem;
|
||||
background: $light-300;
|
||||
border-radius: 99rem;
|
||||
font-style: normal;
|
||||
margin-left: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__breadcrumbs {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
color: $gray-400;
|
||||
overflow: hidden;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
> li {
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:not(:first-child)::before {
|
||||
content: '›';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -55%);
|
||||
left: -0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, 'md')) {
|
||||
.courseware-search__content {
|
||||
padding-top: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
body._search-no-scroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
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';
|
||||
117
src/course-home/courseware-search/map-search-response.js
Normal file
117
src/course-home/courseware-search/map-search-response.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const endpointSchema = Joi.object({
|
||||
took: Joi.number(),
|
||||
total: Joi.number(),
|
||||
maxScore: Joi.number().allow(null),
|
||||
results: Joi.array().items(Joi.object({
|
||||
id: Joi.string(),
|
||||
contentType: Joi.string(),
|
||||
location: Joi.array().items(Joi.string()),
|
||||
url: Joi.string(),
|
||||
content: Joi.object({
|
||||
displayName: Joi.string(),
|
||||
htmlContent: Joi.string(),
|
||||
transcriptEn: Joi.string(),
|
||||
}),
|
||||
}).unknown(true)).strict(),
|
||||
}).unknown(true).strict();
|
||||
|
||||
const defaultType = 'text';
|
||||
|
||||
// Parses the search results in a convenient way.
|
||||
export default function mapSearchResponse(response, searchKeywords = '') {
|
||||
const { error, value: data } = endpointSchema.validate(response);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error in server response:', error);
|
||||
}
|
||||
|
||||
const keywords = searchKeywords ? searchKeywords.toLowerCase().split(' ') : [];
|
||||
|
||||
const {
|
||||
took: ms,
|
||||
total,
|
||||
maxScore,
|
||||
results: rawResults,
|
||||
} = data;
|
||||
|
||||
const results = rawResults.map(result => {
|
||||
const {
|
||||
score,
|
||||
data: {
|
||||
id,
|
||||
content: {
|
||||
displayName,
|
||||
htmlContent,
|
||||
transcriptEn,
|
||||
},
|
||||
contentType,
|
||||
location,
|
||||
url,
|
||||
},
|
||||
} = result;
|
||||
|
||||
const type = contentType?.toLowerCase() || defaultType;
|
||||
|
||||
const content = htmlContent || transcriptEn || '';
|
||||
const searchContent = content.toLowerCase();
|
||||
let contentHits = 0;
|
||||
if (keywords.length) {
|
||||
keywords.forEach(word => {
|
||||
contentHits += searchContent ? searchContent.toLowerCase().split(word).length - 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
const title = displayName || contentType;
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
location,
|
||||
url,
|
||||
contentHits,
|
||||
score,
|
||||
};
|
||||
});
|
||||
|
||||
const filters = rawResults.reduce((list, result) => {
|
||||
const label = result?.data?.contentType;
|
||||
|
||||
if (!label) { return list; }
|
||||
|
||||
const key = label.toLowerCase();
|
||||
|
||||
const index = list.findIndex(i => i.key === key);
|
||||
|
||||
if (index === -1) {
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
key,
|
||||
label,
|
||||
count: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const newItem = { ...list[index] };
|
||||
newItem.count++;
|
||||
|
||||
const newList = list.slice(0);
|
||||
newList[index] = newItem;
|
||||
|
||||
return newList;
|
||||
}, []);
|
||||
|
||||
filters.sort((a, b) => (a.key > b.key ? 1 : -1));
|
||||
|
||||
return {
|
||||
results,
|
||||
filters,
|
||||
total,
|
||||
maxScore,
|
||||
ms,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import mapSearchResponse from './map-search-response';
|
||||
import mockedResponse from './test-data/mocked-response.json';
|
||||
|
||||
describe('mapSearchResponse', () => {
|
||||
describe('when the response is correct', () => {
|
||||
let response;
|
||||
|
||||
beforeEach(() => {
|
||||
response = mapSearchResponse(camelCaseObject(mockedResponse));
|
||||
});
|
||||
|
||||
it('should match snapshot', () => {
|
||||
expect(response).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match expected filters', () => {
|
||||
const expectedFilters = [
|
||||
{ key: 'capa', label: 'CAPA', count: 7 },
|
||||
{ key: 'sequence', label: 'Sequence', count: 2 },
|
||||
{ key: 'text', label: 'Text', count: 9 },
|
||||
{ key: 'video', label: 'Video', count: 2 },
|
||||
];
|
||||
expect(response.filters).toEqual(expectedFilters);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the a keyword is provided', () => {
|
||||
const searchText = 'Course';
|
||||
|
||||
it('should not count matches title', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText);
|
||||
expect(response.results[0].contentHits).toBe(0);
|
||||
});
|
||||
|
||||
it('should count matches on content', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText);
|
||||
expect(response.results[1].contentHits).toBe(1);
|
||||
});
|
||||
|
||||
it('should ignore capitalization', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText.toUpperCase());
|
||||
expect(response.results[1].contentHits).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the response has a wrong format', () => {
|
||||
it('should throw an error', () => {
|
||||
expect(() => mapSearchResponse({ foo: 'bar' })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
68
src/course-home/courseware-search/messages.js
Normal file
68
src/course-home/courseware-search/messages.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchOpenAction: {
|
||||
id: 'learn.coursewareSerch.openAction',
|
||||
defaultMessage: 'Search within this course',
|
||||
description: 'Aria-label for a button that will pop up Courseware Search.',
|
||||
},
|
||||
searchCloseAction: {
|
||||
id: 'learn.coursewareSerch.closeAction',
|
||||
defaultMessage: 'Close the search form',
|
||||
description: 'Aria-label for a button that will close Courseware Search.',
|
||||
},
|
||||
searchModuleTitle: {
|
||||
id: 'learn.coursewareSerch.searchModuleTitle',
|
||||
defaultMessage: 'Search this course',
|
||||
description: 'Title for the Courseware Search module.',
|
||||
},
|
||||
searchBarPlaceholderText: {
|
||||
id: 'learn.coursewareSerch.searchBarPlaceholderText',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Placeholder text for the Courseware Search input control',
|
||||
},
|
||||
loading: {
|
||||
id: 'learn.coursewareSerch.loading',
|
||||
defaultMessage: 'Searching...',
|
||||
description: 'Screen reader text to use on the spinner while the search is performing.',
|
||||
},
|
||||
searchResultsNone: {
|
||||
id: 'learn.coursewareSerch.searchResultsNone',
|
||||
defaultMessage: 'No results found.',
|
||||
description: 'Text to show when the Courseware Search found no results matching the criteria.',
|
||||
},
|
||||
searchResultsSingular: {
|
||||
id: 'learn.coursewareSerch.searchResultsSingular',
|
||||
defaultMessage: '1 match found for "{keyword}":',
|
||||
description: 'Text to show when the Courseware Search found only one result matching the criteria.',
|
||||
},
|
||||
searchResultsPlural: {
|
||||
id: 'learn.coursewareSerch.searchResultsPlural',
|
||||
defaultMessage: '{total} matches found for "{keyword}":',
|
||||
description: 'Text to show when the Courseware Search found multiple results matching the criteria.',
|
||||
},
|
||||
searchResultsError: {
|
||||
id: 'learn.coursewareSerch.searchResultsError',
|
||||
defaultMessage: 'There was an error on the search process. Please try again in a few minutes. If the problem persists, please contact the support team.',
|
||||
description: 'Error message to show to the users when there\'s an error with the endpoint or the returned payload format.',
|
||||
},
|
||||
|
||||
// These are translations for labeling the filters
|
||||
'filter:none': {
|
||||
id: 'learn.coursewareSerch.filter:none',
|
||||
defaultMessage: 'All content',
|
||||
description: 'Label for the search results filter that shows all content (no filter).',
|
||||
},
|
||||
'filter:text': {
|
||||
id: 'learn.coursewareSerch.filter:text',
|
||||
defaultMessage: 'Text',
|
||||
description: 'Label for the search results filter that shows results with text content.',
|
||||
},
|
||||
'filter:video': {
|
||||
id: 'learn.coursewareSerch.filter:video',
|
||||
defaultMessage: 'Video',
|
||||
description: 'Label for the search results filter that shows results with video content.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
548
src/course-home/courseware-search/test-data/mocked-response.json
Normal file
548
src/course-home/courseware-search/test-data/mocked-response.json
Normal file
@@ -0,0 +1,548 @@
|
||||
{
|
||||
"took": 5,
|
||||
"total": 29,
|
||||
"max_score": 3.4545178,
|
||||
"results": [
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Demo Course Overview"
|
||||
},
|
||||
"content_type": "Sequence",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview"
|
||||
],
|
||||
"excerpt": "Demo <b>Course</b> Overview",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Passing a Course",
|
||||
"html_content": "Passing a COurse After the last assignment in a class has been due, you will see the entry in your student profile change to show progress toward generating your certificate. After the certificate generation process has completed, you will be able to download it from your profile page. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course"
|
||||
],
|
||||
"excerpt": "Passing a <b>Course</b><span class=\"search-results-ellipsis\"></span>Passing a <b>COurse</b> After the last assignment in a class has been due,",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Passing a Course"
|
||||
},
|
||||
"content_type": "Sequence",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course"
|
||||
],
|
||||
"excerpt": "Passing a <b>Course</b>",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Text Input",
|
||||
"capa_content": " Here's a very simple example of a text input question. Depending on the course you may have to observe special text requirements for dates, case sensitivity, etc. Which country contains Paris as its capital? "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"stringresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Text input"
|
||||
],
|
||||
"excerpt": "the <b>course</b> you may have to observe special text requirements for",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02"
|
||||
},
|
||||
"score": 1.5874016
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Pointing on a Picture",
|
||||
"capa_content": " Some course questions may show you an image and ask that you click on it to answer a question. Try this example. (If you are correct you will see our famous green check mark.) Which animal is a kitten? "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"imageresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Pointing on a Picture"
|
||||
],
|
||||
"excerpt": " Some <b>course</b> questions may show you an image and ask that you click on",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c"
|
||||
},
|
||||
"score": 1.5499392
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Getting Answers",
|
||||
"capa_content": " In some courses a \"show answer\" button might appear below a question. When you click on this button, you can see the correct answer (with an explanation) that would receive full credit. How much does it cost to take an edX course? Enter the number of dollars. "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"numericalresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Getting Answers"
|
||||
],
|
||||
"excerpt": " In some <b>course</b>s a \"show answer\" button might appear below a question.",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4"
|
||||
},
|
||||
"score": 1.5003732
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Welcome!",
|
||||
"transcript_en": " ERIC: Hi, and welcome to the edX demonstration course. I'm Eric, and I'm here to help you get a better understanding of how fun and easy it is to take an edX course. So, let's get started. Let me show you how all the parts work together. If at any time you want to skip this video and get a firsthand experience of the demonstration course, all you have to do is click week one to the left. Don't worry, I won't be offended. Let's first look along the top of the page. This area's called the navigation bar. Click on Courseware to interact with your course. Course Info contains course announcements and updates from the course staff. If your course has digital textbooks, this is where you'll find them. Discussion is where you can communicate with the fellow students on topics and projects, and even occasionally with the course staff. When available, the course Wiki acts as a knowledge base for your course. It's a helpful resource. Clicking on Progress will reveal how well you're doing in your studies and exams. When you take the demo course, we'll provide you with a simple progress report matching your results. Let's look at the left column now. The left side of the Courseware screen contains a course navigation bar starting from the top down. Many courses start with an overview of edX and an introduction to the course. Below the overview are segments of the course, which are released as the course progresses. Typically, an edX course is delivered in week by week segments, and have lessons and homeworks you need to complete. Many courses are 10 to 12 weeks long. We made this demonstration course three weeks for simplicity. Let's now look at the learning sequence. Each item in the left column reveals a corresponding learning sequence. Work your way from left to right. Learning sequences can contain lectures, exercises, and interactive lessons that you can complete on your own schedule. Next, let's discover what makes edX fun and unique, its interactivity. edX prides itself on its interactive lessons, which can include demonstrations, visualizations, and virtual environments. You can try out some in the demo course. Interactive lessons are often graded and contribute to your final grade. While the edX platform also supports more traditional question formats like multiple choice, our classes also test your understanding by allowing you to use labs and simulators, and even asking you to write an essay. Example of these graded interactions are in the demo course. You can see how the questions the course uses for gauging your learning process can even be auto graded, or detailed feedback given in real time. So while an edX course might be rigorous, the tools and visualizations are really fun and truly interactive. Finally, there are many ways successful students like to you interact to get the most out of a course. Beyond the discussion forums, you can meet and engage with fellow classmates through a local meet up-- which we highly recommend-- a Google Hangout, or even invite students to join you via Twitter, Facebook, or other social networks. It's a proven fact that if you engage with others while taking a course, you're more likely to succeed. Now that you've seen how easy it is to take an edX course, experience this demonstration course. Firsthand all you have to do is click on week one to the left and you can get started. "
|
||||
},
|
||||
"content_type": "Video",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences"
|
||||
],
|
||||
"excerpt": " ERIC: Hi, and welcome to the edX demonstration <b>course</b>. I'm Eric, and",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd"
|
||||
},
|
||||
"score": 1.4792063
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Multiple Choice Questions",
|
||||
"capa_content": " Many edX courses have homework or exercises you need to complete. Notice the clock image to the left? That means this homework or exercise needs to be completed for you to pass the course. (This can be a bit confusing; the exercise may or may not have a due date prior to the end of the course.) We\u2019ve provided eight (8) examples of how a professor might ask you questions. While the multiple choice question types below are somewhat standard, explore the other question types in the sequence above, like the formula builder- try them all out. As you go through the question types, notice how edX gives you immediate feedback on your responses - it really helps in the learning process. What color is the open ocean on a sunny day? 'yellow','blue','green' Which piece of furniture is built for sitting? a table a desk a chair a bookshelf Which of the following are musical instruments? a piano a tree a guitar a window "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"multiplechoiceresponse",
|
||||
"choiceresponse",
|
||||
"optionresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Multiple Choice Questions"
|
||||
],
|
||||
"excerpt": " Many edX <b>course</b>s have homework or exercises you need to complete.",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4"
|
||||
},
|
||||
"score": 1.4341705
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Numerical Input",
|
||||
"capa_content": " Some course questions ask that you insert numbers into web-text fields, and your answers can be judged exactly - or approximately - according to the question. Note that the edX system uses a period to indicate decimals, so fifteen and three quarters is written \"15.75\", not \"15,75\". Enter the numerical value of Pi: Enter the approximate value of 502*9: Enter the number of fingernails on a healthy human hand. For the purposes of this question, please consider the thumb as a finger: "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"numericalresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Numerical Input"
|
||||
],
|
||||
"excerpt": " Some <b>course</b> questions ask that you insert numbers into web-text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974"
|
||||
},
|
||||
"score": 1.2987298
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Connecting a Circuit and a Circuit Diagram",
|
||||
"transcript_en": "SPEAKER 1: What we see here-- a mess. OK? What we have is a voltmeter and an amp meter. The amp meter measures current, the voltmeter measures voltage of course. And we're measuring the voltage across the light bulb-- the lamp. And we're measuring the current into the lamp. PETER: So we've got a voltmeter measuring across the-- volts meter-- across there. And then we've got an amp reader measuring into. SPEAKER 1: Right. Exactly. Now, we're going to connect that to a battery. The three cell battery that you've seen before. And we're going to see, of course, that the light bulb lit up. And the current we measure is 122 milliamperes going into the light bulb. 122 milliamperes. And the voltage across is from the plus to minus, 4.31 volts. OK? Now, we can do another experiment. Notice how the light bulb is lit up and how much it's lit, approximately. Now, I'm going to reverse the battery so that we connect the battery in the opposite polarity. OK. Go ahead, Peter. PETER: You connect it, I will draw. SPEAKER 1: OK, I'm just doing it. PETER: So I'll swap these. SPEAKER 1: Yes, sir. OK. And what we observe is basically the amp meter is measuring 122 milliamperes in the negative direction. So that means it's measuring the current into the light bulb-- because I've not changed the orientation with respect to the light bulb-- of minus 122 milliamperes. And the voltage across the light bulb, from here to here, is minus 4.29 volts. PETER: Sorry, that's a minus? SPEAKER 1: Yes. PETER: So if we look at the power in the first case, 122 milliamps times 4.31 volts, we get 526 milliwatts. SPEAKER 1: Yep. PETER: If we measure the power in this case over here, minus 122 milliamps times minus 4.29 volts, we get approximately the same thing. So I'm going to round it off to, let's say best guess, 524, maybe 23 or something. No less. SPEAKER 1: OK. PETER: So this is equal to that within measurement error. SPEAKER 1: And of course, you see the power is the power going into the light bulb and coming out as light and heat. OK? We have arranged our measurements by having these associated reference directions, so that this is plus and that's minus, and that the current always goes into the terminal that we label with plus. That always means that the power we measure by multiplying these two numbers is the power going into this device. PETER: So this light bulb is dissipating 524 milliwatts. If we were to do the same calculation for the battery, so current would be going to the positive terminal, we would-- SPEAKER 1: Well, you have to measure it then from there to there. PETER: Yeah. Plus, minus. That's what we're doing. So this would be 4.29 volts. The current would still be minus 122 milliamps. The current's moving in a loop, so here is the same as here, but the signs are swapped. That same calculation would give us minus 524 milliwatts. And that's because the battery is outputting power, whereas the light bulb is dissipating power. SPEAKER 1: Think about it as, if we're measuring the power entering the battery, it's minus 524 milliwatts. OK? That's the way to think about it. This always gives you the power entering that element."
|
||||
},
|
||||
"content_type": "Video",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Video Presentation Styles"
|
||||
],
|
||||
"excerpt": "measures voltage of <b>course</b>. And we're measuring the voltage across the",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6"
|
||||
},
|
||||
"score": 1.1870136
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "",
|
||||
"capa_content": " We are searching for the smallest monthly payment such that we can pay off the entire balance of a loan within a year. The following values might be useful when writing your solution Monthly interest rate = (Annual interest rate) / 12 Monthly payment lower bound = Balance / 12 Monthly payment upper bound = (Balance x (1 + Monthly interest rate)12) / 12 The following variables contain values as described below: balance - the outstanding balance on the credit card annualInterestRate - annual interest rate as a decimal Write a program that uses these bounds and bisection search (for more info check out the Wikipedia page on bisection search) to find the smallest monthly payment to the cent such that we can pay off the debt within a year. Note that if you do not use bisection search, your code will not run - your code only has 30 seconds to run on our servers. If you get a message that states \"Your submission could not be graded. Please recheck your submission and try again. If the problem persists, please notify the course staff.\", check to be sure your code doesn't take too long to run. The code you paste into the following box should not specify the values for the variables balance or annualInterestRate - our test code will define those values before testing your submission. monthlyInterestRate = annualInterestRate/12 lowerBound = balance/12 upperBound = (balance * (1+annualInterestRate/12)**12)/12 originalBalance = balance # Keep testing new payment values # until the balance is +/- $0.02 while abs(balance) > .02: # Reset the value of balance to its original value balance = originalBalance # Calculate a new monthly payment value from the bounds payment = (upperBound - lowerBound)/2 + lowerBound # Test if this payment value is sufficient to pay off the # entire balance in 12 months for month in range(12): balance -= payment balance *= 1+monthlyInterestRate # Reset bounds based on the final value of balance if balance > 0: # If the balance is too big, need higher payment # so we increase the lower bound lowerBound = payment else: # If the balance is too small, we need a lower # payment, so we decrease the upper bound upperBound = payment # When the while loop terminates, we know we have # our answer! print(\"Lowest Payment:\", round(payment, 2)) {\"grader\": \"ps02/bisect/grade_bisect.py\"} Note: Depending on where, and how frequently, you round during this function, your answers may be off a few cents in either direction. Try rounding as few times as possible in order to increase the accuracy of your result. Hints Test Cases to test your code with. Be sure to test these on your own machine - and that you get the same output! - before running your code on this webpage! Note: The automated tests are lenient - if your answers are off by a few cents in either direction, your code is OK. Test Cases: Test Case 1: balance = 320000 annualInterestRate = 0.2 Result Your Code Should Generate: ------------------- Lowest Payment: 29157.09 Test Case 2: balance = 999999 annualInterestRate = 0.18 Result Your Code Should Generate: ------------------- Lowest Payment: 90325.07 The autograder says, \"Your submission could not be graded.\" Help! If the autograder gives you the following message: Your submission could not be graded. Please recheck your submission and try again. If the problem persists, please notify the course staff. Don't panic! There are a few things that might be wrong with your code that you should check out. The number one reason this message appears is because your code timed out. You only get 30 seconds of computation time on our servers. If your code times out, you probably have an infinite loop. What to do? The number 1 thing to do is that you need to run this code in your own local environment. Your code should print one line at the end of the loop. If your code never prints anything out - you have an infinite loop! To debug your infinite loop - check your loop conditional. When will it stop? Try inserting print statements inside your loop that prints out information (like variables) - are you incrementing or decrementing your loop counter correctly? Search the forum for people with similar issues. If your search turns up nothing, make a new post and paste in your loop conditional for others to help you out with. Please don't email the course staff unless your code legitimately works and prints out the correct answers in your local environment. In that case, please email your code file, a screenshot of the code printing out the correct answers in your local environment, and a screenshot of the exact same code not working on the tutor. The course staff is otherwise unable to help debug your problem set via email - we can only address platform issues. "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"coderesponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 2: Get Interactive",
|
||||
"Homework - Labs and Demos",
|
||||
"Code Grader"
|
||||
],
|
||||
"excerpt": "notify the <b>course</b> staff.\", check to be sure your code doesn't take too",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader"
|
||||
},
|
||||
"score": 1.0107487
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Interactive Questions",
|
||||
"capa_content": " Most courses have interactive questions that test your knowledge like the one below. They can be part of a learning sequence or an exam. Notice the visual feedback. Go ahead, try it out! Questions which are part of assignments or exams may have due dates - the last possible time you can submit an assignment for grading. Once this time has passed, you will not be able to get credit for any incomplete problems in the assignment. If an assignment has a due date, you can see the due date in the sidebar. (This demo course does not have any assignments with due dates.) If no due date is displayed, the assignment can be turned in at any time. All assignment due dates are displayed in the time zone that you select in your account settings. If you do not specify a time zone, assignment due dates display in your browser's time zone. What kinds of late policies does edX allow? late penalties instructor forgiveness late time budget none of the above "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"choiceresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Interactive Questions"
|
||||
],
|
||||
"excerpt": " Most <b>course</b>s have interactive questions that test your knowledge like",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618"
|
||||
},
|
||||
"score": 0.96387196
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Blank HTML Page",
|
||||
"html_content": "Welcome to the Open edX Demo Course Introduction. This is where you can explore how to take an edX course (like this one). Most courses have an \"intro\" video that shows you how it all works. You can watch the introduction video (below) or scroll though the course studies and assignments using the toolbar (above). Just for fun, we'll keep track of your work in this demo course, and show you your progress in the toolbar just like in a real course. Watch the overview video (below), then click on \"Example Week One\" in the left hand navigation to get started. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences"
|
||||
],
|
||||
"excerpt": "Welcome to the Open edX Demo <b>Course</b> Introduction. This is where you",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
|
||||
},
|
||||
"score": 0.8844358
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Discussion Forums",
|
||||
"html_content": "Discussion FORUMS The discussion forum for each course is found at the top of the course page. You might come across a subset of the discussion forum inside the course (see below), where you can talk with fellow students about the course in context. Go ahead and be social! Make your first post in this demo course. Keep an eye out for posts with a green check mark. The green check means the post has been recognized by a staff member or forum moderator as a great post. You can also actively upvote a post. Others can search on user \u201cupvoted\u201d posts. They tend to be very helpful. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Discussion Forums"
|
||||
],
|
||||
"excerpt": "Discussion FORUMS The discussion forum for each <b>course</b> is found at the",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7"
|
||||
},
|
||||
"score": 0.8803684
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Overall Grade",
|
||||
"html_content": " OVERALL GRADE PERFORMANCE The progress tab (selectable near the top of each page in your course) shows your performance. Click on it now, and you will see how you're doing in this demo course. The bar chart shows the overall percentage that you have earned on each assignment in the course, and how each of those assignments combine into your overall grade. Further down the page is a detailed breakdown of your score on every graded question in the class. You might notice that some of your assignments on the bar chart show an 'x'. The 'x's indicate the assignments that the edX system will NOT be counting toward your final grade, according to the course grading. The 'x's go to the assignments that you scored the lowest on. Each course has its own percentage cutoff for a Certificate of Mastery. You can see where those cutoffs are by looking at the vertical description. In this demo, a \"pass\" is considered 60%. When you \"pass\" a live edX course, you will receive a certificate after the class has closed. Sorry - the demo course does not grant certificates! "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Overall Grade Performance"
|
||||
],
|
||||
"excerpt": "of each page in your <b>course</b>) shows your performance. Click on it now,",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c"
|
||||
},
|
||||
"score": 0.87981963
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Blank HTML Page",
|
||||
"html_content": "Find Your Study Buddy Working with other students offline can help you get the most out of an online course and even increase the likelihood you will successfully complete the course. So, your homework is to find a study buddy. The course specific discussion forums are a great place to find neighbors or even new friends to invite to a Meetup you are looking to organize or even a virtual Google Hangout. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Homework - Find Your Study Buddy"
|
||||
],
|
||||
"excerpt": "get the most out of an online <b>course</b> and even increase the likelihood",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339"
|
||||
},
|
||||
"score": 0.84284115
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Find Your Study Buddy",
|
||||
"html_content": "Find Your Study Buddy Working with other students offline can help you get the most out of an online course and even increase the likelihood you will successfully complete the course. So, your homework is to find a study buddy. The course specific discussion forums are a great place to find neighbors or even new friends to invite to a Meetup you are looking to organize or even a virtual Google Hangout. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
"Homework - Find Your Study Buddy"
|
||||
],
|
||||
"excerpt": "get the most out of an online <b>course</b> and even increase the likelihood",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5"
|
||||
},
|
||||
"score": 0.84284115
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Be Social",
|
||||
"html_content": "Be SOCIAL A big part of learning online includes \u201cbeing social.\u201d We encourage all students to communicate within the course discussion forums \u2013 a great place to connect with other students and to get support from the course staff. Some students and professors also engage through other social mediums like Meetup or Facebook. Recent research has found that if you take a class with a friend, or engage socially with other learners while taking a course, there is a higher likelihood that you will complete a course. If you haven\u2019t already, consider finding a study buddy! Check out more information about the discussion forum by navigating to the next item in this learning sequence. In the discussion forums, remember to be polite and respectful. Simply put, treat others the way you want to be treated. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Be Social"
|
||||
],
|
||||
"excerpt": "encourage all students to communicate within the <b>course</b> discussion",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0"
|
||||
},
|
||||
"score": 0.84210813
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "EdX Exams",
|
||||
"html_content": " EDX EXAMS Not all edX courses have exams; many do, but not all. When choosing a course, it's a good idea to check the exam and study requirements, as well as any prerequisites. Of course - you can \"audit\" any edX course, which means you can study alongside other students using the same content, tools and materials, but you're not focused on grades and might skip the exams and assignments. Follow this learning sequence via the links above to understand more about how we grade your work and track your progress. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"EdX Exams"
|
||||
],
|
||||
"excerpt": " EDX EXAMS Not all edX <b>course</b>s have exams; many do, but not all. When",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530"
|
||||
},
|
||||
"score": 0.8306555
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "When Are Your Exams? ",
|
||||
"html_content": "WHEN ARE YOUR Exams? Every course treats the timing on its exams differently, and you should be really careful to pay attention to any announcements about exam timing that your course makes. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"When Are Your Exams? "
|
||||
],
|
||||
"excerpt": "WHEN ARE YOUR Exams? Every <b>course</b> treats the timing on its exams",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
|
||||
},
|
||||
"score": 0.82610154
|
||||
}
|
||||
],
|
||||
"access_denied_count": 0
|
||||
}
|
||||
@@ -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,20 @@ export async function unsubscribeFromCourseGoal(token) {
|
||||
return getAuthenticatedHttpClient().post(url.href)
|
||||
.then(res => camelCaseObject(res));
|
||||
}
|
||||
|
||||
export async function getCoursewareSearchEnabledFlag(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href);
|
||||
return { enabled: data.enabled || false };
|
||||
}
|
||||
|
||||
export async function searchCourseContentFromAPI(courseId, searchKeyword, options = {}) {
|
||||
const defaults = { page: 0, limit: 20 };
|
||||
const { page, limit } = { ...defaults, ...options };
|
||||
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/search/${courseId}`);
|
||||
const formData = `search_string=${searchKeyword}&page_size=${limit}&page_index=${page}`;
|
||||
const response = await getAuthenticatedHttpClient().post(url.href, formData);
|
||||
|
||||
return camelCaseObject(response);
|
||||
}
|
||||
|
||||
@@ -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,194 +28,193 @@ const provider = new Pact({
|
||||
describe('Course Home Service', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
await provider
|
||||
.setup()
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
mergeConfig({
|
||||
LMS_BASE_URL: 'http://localhost:8081',
|
||||
}, 'Custom app config for pact tests');
|
||||
});
|
||||
|
||||
afterEach(() => provider.verify());
|
||||
afterAll(() => provider.finalize());
|
||||
describe('When a request to fetch tab is made', () => {
|
||||
it('returns tab data for a course_id', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Tab data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/course_metadata/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: false,
|
||||
}),
|
||||
course_access: {
|
||||
has_access: boolean(true),
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `Tab data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/course_metadata/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: false,
|
||||
}),
|
||||
course_access: {
|
||||
has_access: boolean(true),
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
},
|
||||
course_id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
is_enrolled: boolean(true),
|
||||
is_self_paced: boolean(false),
|
||||
is_staff: boolean(true),
|
||||
number: string('DemoX'),
|
||||
org: string('edX'),
|
||||
original_user_is_staff: boolean(true),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
tabs: eachLike({
|
||||
tab_id: 'courseware',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
},
|
||||
course_id: term({
|
||||
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||
matcher: opaqueKeysRegex,
|
||||
}),
|
||||
is_enrolled: boolean(true),
|
||||
is_self_paced: boolean(false),
|
||||
is_staff: boolean(true),
|
||||
number: string('DemoX'),
|
||||
org: string('edX'),
|
||||
original_user_is_staff: boolean(true),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
tabs: eachLike({
|
||||
tab_id: 'courseware',
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
isEnrolled: true,
|
||||
isMasquerading: false,
|
||||
isSelfPaced: false,
|
||||
isStaff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
originalUserIsStaff: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
tabs: [
|
||||
{
|
||||
slug: 'outline',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
isEnrolled: true,
|
||||
isMasquerading: false,
|
||||
isSelfPaced: false,
|
||||
isStaff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
originalUserIsStaff: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
tabs: [
|
||||
{
|
||||
slug: 'outline',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
},
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
},
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to fetch dates tab is made', () => {
|
||||
it('returns course date blocks for a course_id', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `course date blocks exist for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch dates tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/dates/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
dates_banner_info: like({
|
||||
missed_deadlines: false,
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
course_date_blocks: eachLike({
|
||||
assignment_type: null,
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
state: `course date blocks exist for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch dates tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/dates/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
dates_banner_info: like({
|
||||
missed_deadlines: false,
|
||||
content_type_gating_enabled: false,
|
||||
missed_gated_content: false,
|
||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
course_date_blocks: eachLike({
|
||||
assignment_type: null,
|
||||
complete: null,
|
||||
date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
date_type: term({
|
||||
generate: 'verified-upgrade-deadline',
|
||||
matcher: dateTypeRegex,
|
||||
}),
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learner_has_access: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extra_info: null,
|
||||
first_component_block_id: '',
|
||||
}),
|
||||
has_ended: boolean(false),
|
||||
learner_is_full_access: boolean(true),
|
||||
user_timezone: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const camelCaseResponse = {
|
||||
datesBannerInfo: {
|
||||
missedDeadlines: false,
|
||||
contentTypeGatingEnabled: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
courseDateBlocks: [
|
||||
{
|
||||
assignmentType: null,
|
||||
complete: null,
|
||||
date: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
date_type: term({
|
||||
generate: 'verified-upgrade-deadline',
|
||||
matcher: dateTypeRegex,
|
||||
}),
|
||||
date: '2013-02-05T05:00:00Z',
|
||||
dateType: 'verified-upgrade-deadline',
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learner_has_access: true,
|
||||
learnerHasAccess: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
linkText: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extra_info: null,
|
||||
first_component_block_id: '',
|
||||
}),
|
||||
has_ended: boolean(false),
|
||||
learner_is_full_access: boolean(true),
|
||||
user_timezone: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const camelCaseResponse = {
|
||||
datesBannerInfo: {
|
||||
missedDeadlines: false,
|
||||
contentTypeGatingEnabled: false,
|
||||
missedGatedContent: false,
|
||||
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
courseDateBlocks: [
|
||||
{
|
||||
assignmentType: null,
|
||||
complete: null,
|
||||
date: '2013-02-05T05:00:00Z',
|
||||
dateType: 'verified-upgrade-deadline',
|
||||
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
|
||||
learnerHasAccess: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
linkText: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extraInfo: null,
|
||||
firstComponentBlockId: '',
|
||||
},
|
||||
],
|
||||
hasEnded: false,
|
||||
learnerIsFullAccess: true,
|
||||
userTimezone: null,
|
||||
};
|
||||
|
||||
const response = await getDatesTabData(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
extraInfo: null,
|
||||
firstComponentBlockId: '',
|
||||
},
|
||||
],
|
||||
hasEnded: false,
|
||||
learnerIsFullAccess: true,
|
||||
userTimezone: null,
|
||||
};
|
||||
const response = getDatesTabData(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,10 +12,12 @@ import {
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
getLiveTabIframe,
|
||||
getCoursewareSearchEnabledFlag,
|
||||
searchCourseContentFromAPI,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
addModel,
|
||||
addModel, updateModel,
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
setCallToActionToast,
|
||||
} from './slice';
|
||||
|
||||
import mapSearchResponse from '../courseware-search/map-search-response';
|
||||
|
||||
const eventTypes = {
|
||||
POST_EVENT: 'post_event',
|
||||
};
|
||||
@@ -139,3 +143,70 @@ export function processEvent(eventData, getTabData) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCoursewareSearchSettings(courseId) {
|
||||
try {
|
||||
const { enabled } = await getCoursewareSearchEnabledFlag(courseId);
|
||||
return { enabled };
|
||||
} catch (e) {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function searchCourseContent(courseId, searchKeyword) {
|
||||
return async (dispatch) => {
|
||||
const start = new Date();
|
||||
|
||||
dispatch(addModel({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: courseId,
|
||||
searchKeyword,
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading: true,
|
||||
},
|
||||
}));
|
||||
|
||||
let data;
|
||||
let curatedResponse;
|
||||
let errors;
|
||||
try {
|
||||
({ data } = await searchCourseContentFromAPI(courseId, searchKeyword));
|
||||
curatedResponse = mapSearchResponse(data, searchKeyword);
|
||||
} catch (e) {
|
||||
// TODO: Remove when publishing to prod. Just temporary for performance debugging.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error on Courseware Search: ', e.message);
|
||||
errors = e.message;
|
||||
}
|
||||
|
||||
dispatch(updateModel({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
...curatedResponse,
|
||||
id: courseId,
|
||||
searchKeyword,
|
||||
errors,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
|
||||
const end = new Date();
|
||||
const clientMs = (end - start);
|
||||
const {
|
||||
took, total, maxScore, accessDeniedCount,
|
||||
} = data;
|
||||
|
||||
// TODO: Remove when publishing to prod. Just temporary for performance debugging.
|
||||
// eslint-disable-next-line no-console
|
||||
console.table({
|
||||
'Search Keyword': searchKeyword,
|
||||
'Client time (ms)': clientMs,
|
||||
'Server time (ms)': took,
|
||||
'Total matches': total,
|
||||
'Max score': maxScore,
|
||||
'Access denied count': accessDeniedCount,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -119,11 +119,11 @@ describe('Outline Tab', () => {
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
||||
|
||||
// Click to collapse section
|
||||
userEvent.click(expandButton);
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
|
||||
});
|
||||
|
||||
it('displays correct icon for complete assignment', async () => {
|
||||
|
||||
@@ -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,39 @@ 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 />}
|
||||
</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-section')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CoursewareSearch if the flag is on', () => {
|
||||
useCoursewareSearchState.mockImplementation(() => ({ show: true }));
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-section')).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 } from '../setupTest';
|
||||
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');
|
||||
@@ -211,7 +212,7 @@ describe('CoursewareContainer', () => {
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -234,7 +235,7 @@ describe('CoursewareContainer', () => {
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -284,7 +285,7 @@ describe('CoursewareContainer', () => {
|
||||
describe('when the URL does not contain a unit ID', () => {
|
||||
it('should choose a unit within the section\'s first sequence', async () => {
|
||||
setUrl(sectionTree[1].id);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container, 2);
|
||||
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
|
||||
@@ -359,7 +360,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -378,7 +379,7 @@ describe('CoursewareContainer', () => {
|
||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -395,7 +396,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should load the specified unit', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||
const container = await loadContainer();
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container);
|
||||
@@ -411,12 +412,12 @@ describe('CoursewareContainer', () => {
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
||||
const container = await loadContainer();
|
||||
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,13 +10,13 @@ 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';
|
||||
import SidebarTriggers from './sidebar/SidebarTriggers';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage';
|
||||
|
||||
const Course = ({
|
||||
courseId,
|
||||
@@ -53,19 +53,6 @@ const Course = ({
|
||||
const shouldDisplayTriggers = windowWidth >= breakpoints.small.minWidth;
|
||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||
|
||||
// Responsive breakpoints for showing the notification button/tray
|
||||
const shouldDisplayNotificationTrayOpenOnLoad = windowWidth > breakpoints.medium.minWidth;
|
||||
|
||||
// Course specific notification tray open/closed persistance by browser session
|
||||
if (!getSessionStorage(`notificationTrayStatus.${courseId}`)) {
|
||||
if (shouldDisplayNotificationTrayOpenOnLoad) {
|
||||
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
|
||||
} else {
|
||||
// responsive version displays the tray closed on initial load, set the sessionStorage to closed
|
||||
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
setFirstSectionCelebrationOpen(shouldCelebrateOnSectionLoad(
|
||||
@@ -91,7 +78,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,34 +132,78 @@ 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).not.toHaveClass('mt-3', { exact: true });
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
|
||||
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
|
||||
});
|
||||
|
||||
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).toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(discussionsTrigger);
|
||||
});
|
||||
await expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(discussionsTrigger);
|
||||
});
|
||||
await expect(discussionsSideBar).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')).toHaveClass('d-none');
|
||||
const discussionsTrigger = screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
fireEvent.click(discussionsTrigger);
|
||||
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} />);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe(null);
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('open');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).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 });
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe(null);
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('open');
|
||||
|
||||
// Mock reload window, this doesn't happen in the Course component,
|
||||
// calling the reload to check if the tray remains closed
|
||||
@@ -168,28 +213,27 @@ describe('Course', () => {
|
||||
window.location.reload();
|
||||
expect(window.location.reload).toHaveBeenCalled();
|
||||
window.location = location;
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('open');
|
||||
expect(screen.queryByTestId('NotificationTray')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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"');
|
||||
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, 'open');
|
||||
|
||||
render(<Course {...mockData} />);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe(null);
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
fireEvent.click(notificationShowButton);
|
||||
|
||||
// Verify sessionStorage was updated for the original course
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('open');
|
||||
|
||||
// Verify the second course sessionStorage was not changed
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`)).toBe('"open"');
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`)).toBe('open');
|
||||
});
|
||||
|
||||
it('renders course breadcrumbs as expected', async () => {
|
||||
@@ -208,7 +252,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 +261,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 +284,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 +313,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 +347,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 +381,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);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
|
||||
} from '../../../setupTest';
|
||||
import { BookmarkButton } from './index';
|
||||
import { getBookmarksBaseUrl } from './data/api';
|
||||
|
||||
describe('Bookmark Button', () => {
|
||||
let axiosMock;
|
||||
@@ -32,7 +32,8 @@ describe('Bookmark Button', () => {
|
||||
mockData.unitId = nonBookmarkedUnitBlock.id;
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
const bookmarkUrl = getBookmarksBaseUrl();
|
||||
|
||||
axiosMock.onPost(bookmarkUrl).reply(200, { });
|
||||
|
||||
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
export const getBookmarksBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
|
||||
export async function createBookmark(usageId) {
|
||||
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
|
||||
return getAuthenticatedHttpClient().post(getBookmarksBaseUrl(), { usage_id: usageId });
|
||||
}
|
||||
|
||||
export async function deleteBookmark(usageId) {
|
||||
const { username } = getAuthenticatedUser();
|
||||
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
|
||||
return getAuthenticatedHttpClient().delete(`${getBookmarksBaseUrl()}${username},${usageId}/`);
|
||||
}
|
||||
|
||||
86
src/courseware/course/chat/Chat.jsx
Normal file
86
src/courseware/course/chat/Chat.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Xpert } from '@edx/frontend-lib-learning-assistant';
|
||||
import { hasActiveExamAttempt } from '@edx/frontend-lib-special-exams';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
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 [hasActiveAttempt, setHasActiveAttempt] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setHasActiveAttempt(hasActiveExamAttempt());
|
||||
console.log(hasActiveExamAttempt());
|
||||
}, [hasActiveExamAttempt, setHasActiveAttempt]);
|
||||
|
||||
const isEnrolled = (
|
||||
enrollmentMode !== null
|
||||
&& enrollmentMode !== undefined
|
||||
&& [...VERIFIED_MODES, ...AUDIT_MODES].some(mode => mode === enrollmentMode)
|
||||
);
|
||||
|
||||
const shouldDisplayChat = (
|
||||
enabled
|
||||
&& !hasActiveAttempt
|
||||
&& (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({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.content-tools {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
|
||||
@@ -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,257 +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 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);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
} 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]);
|
||||
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,177 +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('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 (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user