Compare commits
50 Commits
zhancock/r
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a853f8ce0c | ||
|
|
d64a4e448b | ||
|
|
860b3f9952 | ||
|
|
4418c5422f | ||
|
|
372c9de1db | ||
|
|
65adaf18d4 | ||
|
|
ed77465282 | ||
|
|
f5f6747ecb | ||
|
|
fbe16483ac | ||
|
|
e4a0105042 | ||
|
|
1d19ae0e7b | ||
|
|
b9d11982e3 | ||
|
|
73590f1ccd | ||
|
|
f8d35bf45d | ||
|
|
2038bad822 | ||
|
|
6c11947397 | ||
|
|
26565cd89c | ||
|
|
3a203e8351 | ||
|
|
500e4abcb9 | ||
|
|
d78851bb5b | ||
|
|
8c9a43d02b | ||
|
|
13c7c1de89 | ||
|
|
ba44b28cec | ||
|
|
79affe0629 | ||
|
|
d0ec7e3fb2 | ||
|
|
3f8b8077a9 | ||
|
|
290f17d76d | ||
|
|
64a1149550 | ||
|
|
e907ade40a | ||
|
|
68a7bf5527 | ||
|
|
db75ea28e4 | ||
|
|
ba4bdfe6af | ||
|
|
b63508db97 | ||
|
|
82b27e59cc | ||
|
|
ec8b5c5d6e | ||
|
|
dc1e9cd2e8 | ||
|
|
a681333a08 | ||
|
|
7cbbc720d1 | ||
|
|
863a838e6e | ||
|
|
5b046e828a | ||
|
|
a8f72c5e75 | ||
|
|
bb6c678904 | ||
|
|
71c2a31531 | ||
|
|
99a44dda37 | ||
|
|
5ae86465cc | ||
|
|
6e9c105eb9 | ||
|
|
26199fa954 | ||
|
|
3a542766d7 | ||
|
|
bbe03dc46f | ||
|
|
167d51b596 |
1
.env
1
.env
@@ -4,6 +4,7 @@
|
||||
NODE_ENV='production'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
APP_ID='learning'
|
||||
BASE_URL=''
|
||||
CONTACT_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
NODE_ENV='development'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
APP_ID='learning'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
NODE_ENV='test'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
APP_ID='learning'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
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/lockfile-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
31
.github/workflows/validate.yml
vendored
31
.github/workflows/validate.yml
vendored
@@ -9,16 +9,35 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [18, 20]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report-${{ matrix.node }}
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
name: code-coverage-report-20
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
1
Makefile
1
Makefile
@@ -57,6 +57,7 @@ validate:
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
npm run bundlewatch
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
|
||||
66
README.rst
66
README.rst
@@ -21,25 +21,19 @@ Getting Started
|
||||
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`_ is currently recommended as a development environment for the Learning
|
||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
||||
make some changes in order to run it in development mode. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
||||
guide below.
|
||||
|
||||
.. _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.
|
||||
|
||||
- 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
|
||||
===================
|
||||
Cloning and Setup
|
||||
=================
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
@@ -47,24 +41,62 @@ Cloning and Startup
|
||||
|
||||
git clone https://github.com/openedx/frontend-app-learning.git
|
||||
|
||||
2. Use node v18.x.
|
||||
2. Use node v20.x.
|
||||
|
||||
The current version of the micro-frontend build scripts supports 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:
|
||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
||||
|
||||
4. Next, we need to tell Tutor that we're going to be running this repo in
|
||||
development mode, and it should be excluded from the ``mfe`` container that
|
||||
otherwise runs every MFE. Run this:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor mounts add /path/to/frontend-app-learning
|
||||
|
||||
5. Start Tutor in development mode. This command will start the LMS and Studio,
|
||||
and other required MFEs like ``authn`` and ``account``, but will not start
|
||||
the learning MFE, which we're going to run on the host instead of in a
|
||||
container managed by Tutor. Run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev start lms cms mfe
|
||||
|
||||
Startup
|
||||
=======
|
||||
|
||||
1. Install npm dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cd frontend-app-learning && npm ci
|
||||
|
||||
4. Start the dev server:
|
||||
2. Start the dev server:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm start
|
||||
npm run dev
|
||||
|
||||
Then you can access the app at http://local.openedx.io:2000/learning/
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
|
||||
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
|
||||
these commands to update your devstack's domain names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev stop
|
||||
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
|
||||
tutor dev launch -I --skip-build
|
||||
tutor dev stop learning # We will run this MFE on the host
|
||||
|
||||
Local module development
|
||||
=========================
|
||||
|
||||
@@ -13,6 +13,6 @@ metadata:
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
spec:
|
||||
owner: group:2u-aurora
|
||||
owner: group:committers-frontend-app-learning
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
|
||||
@@ -9,12 +9,12 @@ const config = 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',
|
||||
'@src/(.*)': '<rootDir>/src/$1',
|
||||
// Explicit mapping to ensure Jest resolves the module correctly
|
||||
'@edx/frontend-lib-special-exams': '<rootDir>/node_modules/@edx/frontend-lib-special-exams',
|
||||
},
|
||||
testTimeout: 30000,
|
||||
globalSetup: "./global-setup.js",
|
||||
@@ -26,7 +26,7 @@ const config = createConfig('jest', {
|
||||
|
||||
config.reporters = [...(config.reporters || []), ["jest-console-group-reporter", {
|
||||
// change this setting if need to see less details for each test
|
||||
// reportType: "summary" | "details",
|
||||
// reportType: "summary" | "details",
|
||||
// enable: true | false,
|
||||
afterEachTest: {
|
||||
enable: true,
|
||||
|
||||
13603
package-lock.json
generated
13603
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
67
package.json
67
package.json
@@ -11,6 +11,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"bundlewatch": "bundlewatch",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
|
||||
@@ -18,6 +19,7 @@
|
||||
"postinstall": "patch-package",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"dev": "PUBLIC_PATH=/learning/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"author": "edX",
|
||||
@@ -31,28 +33,33 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-header": "^5.0.2",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.2.2",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-component-header": "^5.3.3",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.2.4",
|
||||
"@edx/frontend-lib-special-exams": "^3.1.3",
|
||||
"@edx/frontend-platform": "^7.1.2",
|
||||
"@edx/frontend-platform": "^8.0.0",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@edx/react-unit-test-utils": "2.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@edx/react-unit-test-utils": "3.0.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.4",
|
||||
"@openedx/frontend-plugin-framework": "^1.1.2",
|
||||
"@openedx/frontend-build": "14.1.2",
|
||||
"@openedx/frontend-plugin-framework": "^1.2.1",
|
||||
"@openedx/frontend-slot-footer": "^1.0.2",
|
||||
"@openedx/paragon": "^22.3.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "5.3.0",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "2.5.1",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"husky": "7.0.4",
|
||||
"joi": "^17.11.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "17.0.2",
|
||||
@@ -63,34 +70,34 @@
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.8",
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.5"
|
||||
"sass": "^1.79.3",
|
||||
"sass-loader": "^16.0.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"truncate-html": "1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@openedx/frontend-build": "13.1.4",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@pact-foundation/pact": "^13.0.0",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@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",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"husky": "7.0.4",
|
||||
"jest": "^26.6.3",
|
||||
"jest-console-group-reporter": "^1.0.1",
|
||||
"axios-mock-adapter": "2.0.0",
|
||||
"bundlewatch": "^0.4.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.9",
|
||||
"jest": "^29.7.0",
|
||||
"jest-console-group-reporter": "^1.1.1",
|
||||
"jest-when": "^3.6.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"rosie": "2.1.1",
|
||||
"sass": "^1.72.0",
|
||||
"sass-loader": "^14.1.1",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"style-loader": "^3.3.4"
|
||||
"rosie": "2.1.1"
|
||||
},
|
||||
"bundlewatch": {
|
||||
"files": [
|
||||
{
|
||||
"path": "dist/*.js",
|
||||
"maxSize": "1300kB"
|
||||
}
|
||||
],
|
||||
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const DECODE_ROUTES = {
|
||||
|
||||
export const ROUTES = {
|
||||
UNSUBSCRIBE: '/goal-unsubscribe/:token',
|
||||
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch',
|
||||
REDIRECT: '/redirect/*',
|
||||
DASHBOARD: 'dashboard',
|
||||
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
|
||||
@@ -49,3 +50,8 @@ export const WIDGETS = {
|
||||
DISCUSSIONS: 'DISCUSSIONS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
};
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
|
||||
Object {
|
||||
"filters": Array [
|
||||
Object {
|
||||
{
|
||||
"filters": [
|
||||
{
|
||||
"count": 7,
|
||||
"key": "capa",
|
||||
"label": "CAPA",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"count": 2,
|
||||
"key": "sequence",
|
||||
"label": "Sequence",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"count": 9,
|
||||
"key": "text",
|
||||
"label": "Text",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"count": 1,
|
||||
"key": "unknown",
|
||||
"label": "Unknown",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"count": 2,
|
||||
"key": "video",
|
||||
"label": "Video",
|
||||
@@ -31,11 +31,11 @@ Object {
|
||||
],
|
||||
"maxScore": 3.4545178,
|
||||
"ms": 5,
|
||||
"results": Array [
|
||||
Object {
|
||||
"results": [
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
],
|
||||
@@ -44,10 +44,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course",
|
||||
@@ -57,10 +57,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course",
|
||||
@@ -70,10 +70,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Text input",
|
||||
@@ -83,10 +83,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Pointing on a Picture",
|
||||
@@ -96,10 +96,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Getting Answers",
|
||||
@@ -109,10 +109,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences",
|
||||
@@ -122,10 +122,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Multiple Choice Questions",
|
||||
@@ -135,10 +135,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Numerical Input",
|
||||
@@ -148,10 +148,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Video Presentation Styles",
|
||||
@@ -161,10 +161,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 2: Get Interactive",
|
||||
"Homework - Labs and Demos",
|
||||
"Code Grader",
|
||||
@@ -174,10 +174,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Interactive Questions",
|
||||
@@ -187,10 +187,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences",
|
||||
@@ -200,10 +200,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Discussion Forums",
|
||||
@@ -213,10 +213,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Overall Grade Performance",
|
||||
@@ -226,10 +226,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
@@ -239,10 +239,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
"Homework - Find Your Study Buddy",
|
||||
@@ -252,10 +252,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Be Social",
|
||||
@@ -265,10 +265,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"EdX Exams",
|
||||
@@ -278,10 +278,10 @@ Object {
|
||||
"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 [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"When Are Your Exams? ",
|
||||
@@ -291,7 +291,7 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "random-element-id",
|
||||
"location": null,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
{
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -12,13 +12,13 @@ Object {
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseware": {
|
||||
"courseId": null,
|
||||
"courseOutline": Object {},
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": Object {},
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -26,12 +26,12 @@ Object {
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"courseAccess": {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -49,33 +49,33 @@ Object {
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"tabs": [
|
||||
{
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
@@ -84,7 +84,7 @@ Object {
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"verifiedMode": {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -94,10 +94,10 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"dates": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"courseDateBlocks": [
|
||||
{
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
@@ -106,7 +106,7 @@ Object {
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
@@ -116,7 +116,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Completed",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -125,7 +125,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -135,7 +135,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -145,7 +145,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
@@ -156,7 +156,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -166,7 +166,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
@@ -177,7 +177,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
@@ -188,7 +188,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"date": "2020-06-16T17:59:40.942669Z",
|
||||
"dateType": "verified-upgrade-deadline",
|
||||
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
@@ -197,7 +197,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Upgrade to Verified Certificate",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -207,7 +207,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -217,7 +217,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -227,7 +227,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -237,7 +237,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -247,7 +247,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -255,7 +255,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "One Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -265,7 +265,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -274,7 +274,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -283,7 +283,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"dateType": "course-end-date",
|
||||
"description": "",
|
||||
@@ -292,7 +292,7 @@ Object {
|
||||
"link": "",
|
||||
"title": "Course Ends",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"date": "2030-09-01T00:00:00Z",
|
||||
"dateType": "verification-deadline-date",
|
||||
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
|
||||
@@ -302,7 +302,7 @@ Object {
|
||||
"title": "Verification Deadline",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"datesBannerInfo": {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
@@ -314,16 +314,16 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"specialExams": {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -351,27 +351,27 @@ Object {
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"prerequisite_status": {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"examAccessToken": {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"instructions": [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
"rules": {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -382,7 +382,7 @@ Object {
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"tours": {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
@@ -393,8 +393,8 @@ Object {
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
{
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -404,13 +404,13 @@ Object {
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseware": {
|
||||
"courseId": null,
|
||||
"courseOutline": Object {},
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": Object {},
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -418,12 +418,12 @@ Object {
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"courseAccess": {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -441,33 +441,33 @@ Object {
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"tabs": [
|
||||
{
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
@@ -476,7 +476,7 @@ Object {
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"verifiedMode": {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -486,41 +486,41 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"outline": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"accessExpiration": null,
|
||||
"canShowUpgradeSock": false,
|
||||
"certData": Object {
|
||||
"certData": {
|
||||
"certStatus": null,
|
||||
"certWebViewUrl": null,
|
||||
"certificateAvailableDate": null,
|
||||
},
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"courseBlocks": {
|
||||
"courses": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": {
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionIds": Array [
|
||||
"sectionIds": [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"sections": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"hideFromTOC": undefined,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"resumeBlock": false,
|
||||
"sequenceIds": Array [
|
||||
"sequenceIds": [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
],
|
||||
"title": "Title of Section",
|
||||
},
|
||||
},
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"sequences": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
@@ -536,30 +536,30 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseGoals": Object {
|
||||
"courseGoals": {
|
||||
"daysPerWeek": null,
|
||||
"goalOptions": Array [],
|
||||
"goalOptions": [],
|
||||
"selectedGoal": null,
|
||||
"subscribedToReminders": null,
|
||||
"weeklyLearningGoalEnabled": false,
|
||||
},
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
"courseTools": [
|
||||
{
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "https://example.com/bookmarks",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"datesBannerInfo": {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
"datesWidget": {
|
||||
"courseDateBlocks": [],
|
||||
},
|
||||
"enableProctoredExams": undefined,
|
||||
"enrollAlert": Object {
|
||||
"enrollAlert": {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
@@ -569,13 +569,13 @@ Object {
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"offer": null,
|
||||
"resumeCourse": Object {
|
||||
"resumeCourse": {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"timeOffsetMillis": 0,
|
||||
"userHasPassingGrade": undefined,
|
||||
"verifiedMode": Object {
|
||||
"verifiedMode": {
|
||||
"accessExpirationDate": "2050-01-01T12:00:00",
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -587,16 +587,16 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"specialExams": {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -624,27 +624,27 @@ Object {
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"prerequisite_status": {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"examAccessToken": {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"instructions": [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
"rules": {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -655,7 +655,7 @@ Object {
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"tours": {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
@@ -666,8 +666,8 @@ Object {
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
{
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -677,13 +677,13 @@ Object {
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseware": {
|
||||
"courseId": null,
|
||||
"courseOutline": Object {},
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": Object {},
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -691,12 +691,12 @@ Object {
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"courseAccess": {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -714,33 +714,33 @@ Object {
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"tabs": [
|
||||
{
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
@@ -749,7 +749,7 @@ Object {
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"verifiedMode": {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -759,16 +759,16 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"progress": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"accessExpiration": null,
|
||||
"certificateData": Object {},
|
||||
"completionSummary": Object {
|
||||
"certificateData": {},
|
||||
"completionSummary": {
|
||||
"completeCount": 1,
|
||||
"incompleteCount": 1,
|
||||
"lockedCount": 0,
|
||||
},
|
||||
"courseGrade": Object {
|
||||
"courseGrade": {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
@@ -779,10 +779,10 @@ Object {
|
||||
"enrollmentMode": "audit",
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
"gradesFeatureIsPartiallyLocked": false,
|
||||
"gradingPolicy": Object {
|
||||
"assignmentPolicies": Array [
|
||||
Object {
|
||||
"averageGrade": "1.00",
|
||||
"gradingPolicy": {
|
||||
"assignmentPolicies": [
|
||||
{
|
||||
"averageGrade": "1.0000",
|
||||
"numDroppable": 1,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
@@ -790,17 +790,17 @@ Object {
|
||||
"weightedGrade": 1,
|
||||
},
|
||||
],
|
||||
"gradeRange": Object {
|
||||
"gradeRange": {
|
||||
"pass": 0.75,
|
||||
},
|
||||
},
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionScores": Array [
|
||||
Object {
|
||||
"sectionScores": [
|
||||
{
|
||||
"displayName": "First section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"subsections": [
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
|
||||
"displayName": "First subsection",
|
||||
@@ -809,16 +809,16 @@ Object {
|
||||
"numPointsEarned": 0,
|
||||
"numPointsPossible": 3,
|
||||
"percentGraded": 0,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"problemScores": [
|
||||
{
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
@@ -829,18 +829,18 @@ Object {
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"displayName": "Second section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"subsections": [
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"displayName": "Second subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"numPointsEarned": 1,
|
||||
"numPointsPossible": 1,
|
||||
"percentGraded": 1,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"problemScores": [
|
||||
{
|
||||
"earned": 1,
|
||||
"possible": 1,
|
||||
},
|
||||
@@ -854,7 +854,7 @@ Object {
|
||||
],
|
||||
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
|
||||
"userHasPassingGrade": false,
|
||||
"verificationData": Object {
|
||||
"verificationData": {
|
||||
"link": null,
|
||||
"status": "none",
|
||||
"statusDate": null,
|
||||
@@ -863,16 +863,16 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"specialExams": {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -900,27 +900,27 @@ Object {
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"prerequisite_status": {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"examAccessToken": {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"instructions": [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
"rules": {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -931,7 +931,7 @@ Object {
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"tours": {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
|
||||
@@ -18,7 +18,7 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
|
||||
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
||||
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
||||
// exists in edx-platform.
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
|
||||
@@ -19,6 +19,10 @@ const CertificateStatus = ({ intl }) => {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
entranceExamData,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
isEnrolled,
|
||||
org,
|
||||
@@ -42,6 +46,8 @@ const CertificateStatus = ({ intl }) => {
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
const entranceExamPassed = entranceExamData?.entranceExamPassed ?? null;
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
@@ -49,6 +55,7 @@ const CertificateStatus = ({ intl }) => {
|
||||
userHasPassingGrade,
|
||||
null, // CourseExitPageIsActive
|
||||
canViewCertificate,
|
||||
entranceExamPassed,
|
||||
);
|
||||
|
||||
const eventProperties = {
|
||||
|
||||
@@ -36,17 +36,17 @@
|
||||
}
|
||||
|
||||
#passing-grade-tooltip {
|
||||
background: $success-500;
|
||||
|
||||
.arrow::after {
|
||||
border-top-color: $success-500;
|
||||
}
|
||||
|
||||
background: $success-500;
|
||||
}
|
||||
|
||||
#non-passing-grade-tooltip {
|
||||
background: $accent-b;
|
||||
|
||||
.arrow::after {
|
||||
border-top-color: $accent-b;
|
||||
}
|
||||
|
||||
background: $accent-b;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@ celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
|
||||
|
||||
describe('Course', () => {
|
||||
let store;
|
||||
let getItemSpy;
|
||||
let setItemSpy;
|
||||
const mockData = {
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
@@ -52,30 +50,27 @@ describe('Course', () => {
|
||||
global.innerWidth = breakpoints.extraLarge.minWidth;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
getItemSpy.mockRestore();
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('loads learning sequence', async () => {
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
loadUnit();
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
|
||||
const { models } = store.getState();
|
||||
const sequence = models.sequences[mockData.sequenceId];
|
||||
const section = models.sections[sequence.sectionId];
|
||||
const course = models.coursewareMeta[mockData.courseId];
|
||||
expect(document.title).toMatch(
|
||||
`${sequence.title} | ${section.title} | ${course.title} | edX`,
|
||||
);
|
||||
const { models } = store.getState();
|
||||
const sequence = models.sequences[mockData.sequenceId];
|
||||
const section = models.sections[sequence.sectionId];
|
||||
const course = models.coursewareMeta[mockData.courseId];
|
||||
expect(document.title).toMatch(
|
||||
`${sequence.title} | ${section.title} | ${course.title} | edX`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes breadcrumbs when navigation is disabled', async () => {
|
||||
@@ -114,9 +109,11 @@ describe('Course', () => {
|
||||
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays weekly goal celebration modal', async () => {
|
||||
@@ -132,40 +129,40 @@ describe('Course', () => {
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays notification trigger and toggles active class on click', async () => {
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
|
||||
waitFor(() => {
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles click to open/close discussions sidebar', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
|
||||
await waitFor(() => {
|
||||
waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
expect(discussionsTrigger).toBeInTheDocument();
|
||||
fireEvent.click(discussionsTrigger);
|
||||
const discussionsTrigger = screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
expect(discussionsTrigger).toBeInTheDocument();
|
||||
fireEvent.click(discussionsTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(discussionsTrigger);
|
||||
fireEvent.click(discussionsTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -186,9 +183,9 @@ describe('Course', () => {
|
||||
const { rerender } = render(<Course {...testData} />, { store: testStore });
|
||||
loadUnit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
waitFor(() => {
|
||||
expect(screen.findByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.findByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
rerender(null);
|
||||
@@ -196,11 +193,13 @@ describe('Course', () => {
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
waitFor(() => {
|
||||
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders course breadcrumbs as expected', async () => {
|
||||
@@ -224,10 +223,14 @@ describe('Course', () => {
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
});
|
||||
// expect the section and sequence "titles" to be loaded in as breadcrumb labels.
|
||||
expect(screen.getByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
|
||||
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(screen.findByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
|
||||
expect(screen.findByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes handlers to the sequence', async () => {
|
||||
@@ -256,14 +259,16 @@ describe('Course', () => {
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
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();
|
||||
expect(nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
// We are in the middle of the sequence, so no
|
||||
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sequence alerts display', () => {
|
||||
@@ -283,7 +288,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
waitFor(() => expect(screen.findByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with passing score', async () => {
|
||||
@@ -317,7 +322,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
waitFor(() => expect(screen.findByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with non-passing score', async () => {
|
||||
@@ -351,7 +356,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
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());
|
||||
waitFor(() => expect(screen.findByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -370,7 +375,7 @@ describe('Course', () => {
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
await expect(chat).toBeInTheDocument();
|
||||
waitFor(() => expect(chat).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not display chat when screen is too narrow (mobile)', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ const COURSE_EXIT_MODES = {
|
||||
celebration: 1,
|
||||
nonPassing: 2,
|
||||
inProgress: 3,
|
||||
entranceExamFail: 4,
|
||||
};
|
||||
|
||||
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
|
||||
@@ -32,9 +33,14 @@ function getCourseExitMode(
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive = null,
|
||||
canImmediatelyViewCertificate = false,
|
||||
entranceExamPassed = null,
|
||||
) {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
|
||||
if (entranceExamPassed === false) {
|
||||
return COURSE_EXIT_MODES.entranceExamFail;
|
||||
}
|
||||
|
||||
if (courseExitPageIsActive === false || !authenticatedUser || !isEnrolled) {
|
||||
return COURSE_EXIT_MODES.disabled;
|
||||
}
|
||||
@@ -73,6 +79,7 @@ function GetCourseExitNavigation(courseId, intl) {
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
entranceExamData: { entranceExamPassed },
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
const { canViewCertificate } = useModel('courseHomeMeta', courseId);
|
||||
const exitMode = getCourseExitMode(
|
||||
@@ -82,8 +89,15 @@ function GetCourseExitNavigation(courseId, intl) {
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
canViewCertificate,
|
||||
entranceExamPassed,
|
||||
);
|
||||
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;
|
||||
|
||||
/** exitActive is used to enable/disable the exit i.e. next buttons.
|
||||
COURSE_EXIT_MODES denote the current status of the course.
|
||||
Available COURSE_EXIT_MODES: disabled, celebration, nonPassing, inProgress, entranceExamFail
|
||||
If the user fails the entrance exam,
|
||||
access to further course sections should not be allowed i.e. disable the next buttons. */
|
||||
const exitActive = ((exitMode !== COURSE_EXIT_MODES.disabled) && (exitMode !== COURSE_EXIT_MODES.entranceExamFail));
|
||||
|
||||
let exitText;
|
||||
switch (exitMode) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { SIDEBARS } from './sidebars';
|
||||
const Sidebar = () => {
|
||||
const { currentSidebar, isDiscussionbarAvailable, isNotificationbarAvailable } = useContext(SidebarContext);
|
||||
|
||||
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)) { return null; }
|
||||
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)
|
||||
|| !SIDEBARS[currentSidebar]) { return null; }
|
||||
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
|
||||
|
||||
return (
|
||||
|
||||
@@ -61,6 +61,10 @@ const SidebarProvider = ({
|
||||
}
|
||||
}, [hideDiscussionbar, hideNotificationbar]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSidebar(initialSidebar);
|
||||
}, [shouldDisplaySidebarOpen, initialSidebar]);
|
||||
|
||||
const handleWidgetToggle = useCallback((widgetId, sidebarId) => {
|
||||
setHideDiscussionbar(prevWidgetId => (widgetId === WIDGETS.DISCUSSIONS ? true : prevWidgetId));
|
||||
setHideNotificationbar(prevWidgetId => (widgetId === WIDGETS.NOTIFICATIONS ? true : prevWidgetId));
|
||||
@@ -75,11 +79,11 @@ const SidebarProvider = ({
|
||||
}, [currentSidebar, isDiscussionbarAvailable, isNotificationbarAvailable]);
|
||||
|
||||
const clearSidebarKeyIfWidgetsUnavailable = useCallback((widgetId) => {
|
||||
if ((!isNotificationbarAvailable && widgetId === WIDGETS.DISCUSSIONS)
|
||||
|| (!isDiscussionbarAvailable && widgetId === WIDGETS.NOTIFICATIONS)) {
|
||||
if (((!isNotificationbarAvailable || hideNotificationbar) && widgetId === WIDGETS.DISCUSSIONS)
|
||||
|| ((!isDiscussionbarAvailable || hideDiscussionbar) && widgetId === WIDGETS.NOTIFICATIONS)) {
|
||||
setLocalStorage(sidebarKey, null);
|
||||
}
|
||||
}, [isDiscussionbarAvailable, isNotificationbarAvailable]);
|
||||
}, [isDiscussionbarAvailable, isNotificationbarAvailable, hideDiscussionbar, hideNotificationbar]);
|
||||
|
||||
const toggleSidebar = useCallback((sidebarId = null, widgetId = null) => {
|
||||
if (widgetId) {
|
||||
|
||||
@@ -17,7 +17,7 @@ const DiscussionsNotificationsSidebar = () => {
|
||||
<SidebarBase
|
||||
ariaLabel={intl.formatMessage(messages.discussionNotificationTray)}
|
||||
sidebarId={ID}
|
||||
className="d-flex flex-column flex-fill"
|
||||
className="d-flex flex-column flex-fill overflow-auto"
|
||||
showTitleBar={false}
|
||||
showBorder={false}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useContext } from 'react';
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import SidebarContext from '../../../SidebarContext';
|
||||
import messages from '../../../messages';
|
||||
|
||||
@@ -24,7 +25,10 @@ const DiscussionsWidget = () => {
|
||||
return (
|
||||
<iframe
|
||||
src={`${discussionsUrl}?inContextSidebar`}
|
||||
className={classNames('d-flex w-100 flex-fill border border-light-400 rounded-sm', { 'vh-100': !shouldDisplayFullScreen })}
|
||||
className={classNames('d-flex w-100 flex-fill border border-light-400 rounded-sm', {
|
||||
'vh-100': !shouldDisplayFullScreen,
|
||||
'min-height-700': shouldDisplayFullScreen,
|
||||
})}
|
||||
title={intl.formatMessage(messages.discussionsTitle)}
|
||||
allow="clipboard-write"
|
||||
loading="lazy"
|
||||
|
||||
@@ -169,7 +169,6 @@ describe('NotificationsWidget', () => {
|
||||
});
|
||||
|
||||
it('marks notification as seen 3 seconds later', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onNotificationSeen = jest.fn();
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
@@ -184,7 +183,6 @@ describe('NotificationsWidget', () => {
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(0);
|
||||
jest.advanceTimersByTime(3000);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onNotificationSeen).toHaveBeenCalledTimes(1), { timeout: 3500 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Factory } from 'rosie';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
import {
|
||||
loadUnit, render, screen, fireEvent, waitFor, initializeTestStore,
|
||||
loadUnit, render, screen, fireEvent, waitFor, initializeTestStore, act,
|
||||
} from '../../../setupTest';
|
||||
import SidebarContext from '../sidebar/SidebarContext';
|
||||
import Sequence from './Sequence';
|
||||
@@ -103,18 +103,20 @@ describe('Sequence', () => {
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(3);
|
||||
// `Next` button.
|
||||
expect(screen.getAllByRole('link').length).toEqual(1);
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument();
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(3);
|
||||
// `Next` button.
|
||||
expect(screen.getAllByRole('link').length).toEqual(1);
|
||||
|
||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||
const unitContainer = container.querySelector('.unit-container');
|
||||
expect(unitContainer.querySelector('svg')).toHaveClass('fa-lock');
|
||||
expect(screen.getByText(/You must complete the prerequisite/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Go To Prerequisite Section' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||
const unitContainer = container.querySelector('.unit-container');
|
||||
expect(unitContainer.querySelector('svg')).toHaveClass('fa-lock');
|
||||
expect(screen.getByText(/You must complete the prerequisite/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Go To Prerequisite Section' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders correctly for hidden after due content', async () => {
|
||||
@@ -157,19 +159,21 @@ describe('Sequence', () => {
|
||||
|
||||
it('handles loading unit', async () => {
|
||||
render(<SidebarWrapper />, { wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||
// Renders `Next` button.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1);
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||
// Renders `Next` button.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1);
|
||||
|
||||
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/i }).length).toEqual(2);
|
||||
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
|
||||
// Renders two `Next` buttons for top and bottom unit navigations.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||
loadUnit();
|
||||
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/i }).length).toEqual(2);
|
||||
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
|
||||
// Renders two `Next` buttons for top and bottom unit navigations.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequence and unit navigation buttons', () => {
|
||||
@@ -201,31 +205,33 @@ describe('Sequence', () => {
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
|
||||
fireEvent.click(sequencePreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
|
||||
fireEvent.click(sequencePreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
|
||||
.filter(button => button !== sequencePreviousButton)[0];
|
||||
fireEvent.click(unitPreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'bottom',
|
||||
loadUnit();
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
|
||||
.filter(button => button !== sequencePreviousButton)[0];
|
||||
fireEvent.click(unitPreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,30 +243,31 @@ describe('Sequence', () => {
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
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', {
|
||||
current_tab: unitBlocks.length,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
|
||||
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', {
|
||||
current_tab: unitBlocks.length,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
|
||||
.filter(button => button !== sequenceNextButton)[0];
|
||||
fireEvent.click(unitNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: unitBlocks.length,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'bottom',
|
||||
loadUnit();
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
|
||||
.filter(button => button !== sequenceNextButton)[0];
|
||||
fireEvent.click(unitNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: unitBlocks.length,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,19 +282,22 @@ describe('Sequence', () => {
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
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('link', { name: /previous/i }));
|
||||
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
|
||||
|
||||
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`.
|
||||
expect(testData.unitNavigationHandler).toHaveBeenNthCalledWith(2, unitBlocks[unitNumber + 1].id);
|
||||
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`.
|
||||
expect(testData.unitNavigationHandler).toHaveBeenNthCalledWith(2, unitBlocks[unitNumber + 1].id);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the `Previous` buttons for the first unit in the first sequence', async () => {
|
||||
@@ -300,13 +310,15 @@ describe('Sequence', () => {
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
|
||||
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
expect(sendTrackEvent).not.toHaveBeenCalled();
|
||||
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
expect(sendTrackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the `Next` buttons for the last unit in the last sequence', async () => {
|
||||
@@ -319,13 +331,15 @@ describe('Sequence', () => {
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
|
||||
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
expect(sendTrackEvent).not.toHaveBeenCalled();
|
||||
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
expect(sendTrackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the navigation buttons for empty sequence', async () => {
|
||||
@@ -365,39 +379,42 @@ describe('Sequence', () => {
|
||||
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(2);
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'bottom',
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -412,16 +429,17 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
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,
|
||||
id: testData.unitId,
|
||||
target_tab: targetUnitNumber,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'top',
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
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,
|
||||
id: testData.unitId,
|
||||
target_tab: targetUnitNumber,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -429,15 +447,17 @@ describe('Sequence', () => {
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
|
||||
expect(await screen.findByText('Notifications')).toBeInTheDocument();
|
||||
waitFor(async () => 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 }} />, { wrapWithRouter: true });
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalled();
|
||||
act(async () => {
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render notification tray in sequence by default if in responsive view', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { StrictDict } from '@edx/react-unit-test-utils';
|
||||
import { ModalDialog, Modal } from '@openedx/paragon';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
import PageLoading from '@src/generic/PageLoading';
|
||||
import * as hooks from './hooks';
|
||||
@@ -35,6 +36,7 @@ const ContentIFrame = ({
|
||||
elementId,
|
||||
onLoaded,
|
||||
title,
|
||||
courseId,
|
||||
}) => {
|
||||
const {
|
||||
handleIFrameLoad,
|
||||
@@ -82,7 +84,17 @@ const ContentIFrame = ({
|
||||
return (
|
||||
<>
|
||||
{(shouldShowContent && !hasLoaded) && (
|
||||
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
|
||||
showError ? <ErrorPage /> : (
|
||||
<PluginSlot
|
||||
id="content_iframe_loader_slot"
|
||||
pluginProps={{
|
||||
defaultLoaderComponent: <PageLoading srMessage={loadingMessage} />,
|
||||
courseId,
|
||||
}}
|
||||
>
|
||||
<PageLoading srMessage={loadingMessage} />
|
||||
</PluginSlot>
|
||||
)
|
||||
)}
|
||||
{shouldShowContent && (
|
||||
<div className="unit-iframe-wrapper">
|
||||
@@ -124,11 +136,13 @@ ContentIFrame.propTypes = {
|
||||
elementId: PropTypes.string.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
title: PropTypes.node.isRequired,
|
||||
courseId: PropTypes.string,
|
||||
};
|
||||
|
||||
ContentIFrame.defaultProps = {
|
||||
iframeUrl: null,
|
||||
onLoaded: () => ({}),
|
||||
courseId: '',
|
||||
};
|
||||
|
||||
export default ContentIFrame;
|
||||
|
||||
@@ -49,6 +49,7 @@ exports[`Unit component output snapshot: not bookmarked, do not show content 1`]
|
||||
id="test-props-id"
|
||||
/>
|
||||
<ContentIFrame
|
||||
courseId="test-course-id"
|
||||
elementId="unit-iframe"
|
||||
id="test-props-id"
|
||||
loadingMessage="Loading learning sequence..."
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
@@ -85,6 +86,49 @@ const useIFrameBehavior = ({
|
||||
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
// Send visibility status to the iframe. It's used to mark XBlocks as viewed.
|
||||
React.useEffect(() => {
|
||||
if (!hasLoaded) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const iframeElement = document.getElementById(elementId);
|
||||
if (!iframeElement || !iframeElement.contentWindow) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateIframeVisibility = () => {
|
||||
const rect = iframeElement.getBoundingClientRect();
|
||||
const visibleInfo = {
|
||||
type: 'unit.visibilityStatus',
|
||||
data: {
|
||||
topPosition: rect.top,
|
||||
viewportHeight: window.innerHeight,
|
||||
},
|
||||
};
|
||||
iframeElement.contentWindow.postMessage(
|
||||
visibleInfo,
|
||||
`${getConfig().LMS_BASE_URL}`,
|
||||
);
|
||||
};
|
||||
|
||||
// Throttle the update function to prevent it from sending too many messages to the iframe.
|
||||
const throttledUpdateVisibility = throttle(updateIframeVisibility, 100);
|
||||
|
||||
// Update the visibility of the iframe in case the element is already visible.
|
||||
updateIframeVisibility();
|
||||
|
||||
// Add event listeners to update the visibility of the iframe when the window is scrolled or resized.
|
||||
window.addEventListener('scroll', throttledUpdateVisibility);
|
||||
window.addEventListener('resize', throttledUpdateVisibility);
|
||||
|
||||
// Clean up event listeners on unmount.
|
||||
return () => {
|
||||
window.removeEventListener('scroll', throttledUpdateVisibility);
|
||||
window.removeEventListener('resize', throttledUpdateVisibility);
|
||||
};
|
||||
}, [hasLoaded, elementId]);
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -30,6 +30,11 @@ jest.mock('react-redux', () => ({
|
||||
useDispatch: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lodash', () => ({
|
||||
...jest.requireActual('lodash'),
|
||||
throttle: jest.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
jest.mock('./useLoadBearingHook', () => jest.fn());
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
@@ -64,7 +69,10 @@ const dispatch = jest.fn();
|
||||
useDispatch.mockReturnValue(dispatch);
|
||||
|
||||
const postMessage = jest.fn();
|
||||
const frame = { contentWindow: { postMessage } };
|
||||
const frame = {
|
||||
contentWindow: { postMessage },
|
||||
getBoundingClientRect: jest.fn(() => ({ top: 100 })),
|
||||
};
|
||||
const mockGetElementById = jest.fn(() => frame);
|
||||
const testHash = '#test-hash';
|
||||
|
||||
@@ -87,6 +95,10 @@ describe('useIFrameBehavior hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
state.mock();
|
||||
global.document.getElementById = mockGetElementById;
|
||||
global.window.addEventListener = jest.fn();
|
||||
global.window.removeEventListener = jest.fn();
|
||||
global.window.innerHeight = 800;
|
||||
});
|
||||
afterEach(() => {
|
||||
state.resetVals();
|
||||
@@ -265,6 +277,53 @@ describe('useIFrameBehavior hook', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('visibility tracking', () => {
|
||||
it('sets up visibility tracking after iframe has loaded', () => {
|
||||
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
||||
useIFrameBehavior(props);
|
||||
|
||||
const effects = getEffects([true, props.elementId], React);
|
||||
expect(effects.length).toEqual(2);
|
||||
effects[0](); // Execute the visibility tracking effect.
|
||||
|
||||
expect(global.window.addEventListener).toHaveBeenCalledTimes(2);
|
||||
expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||
// Initial visibility update.
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'unit.visibilityStatus',
|
||||
data: {
|
||||
topPosition: 100,
|
||||
viewportHeight: 800,
|
||||
},
|
||||
},
|
||||
config.LMS_BASE_URL,
|
||||
);
|
||||
});
|
||||
it('does not set up visibility tracking before iframe has loaded', () => {
|
||||
state.mockVals({ ...defaultStateVals, hasLoaded: false });
|
||||
useIFrameBehavior(props);
|
||||
|
||||
const effects = getEffects([false, props.elementId], React);
|
||||
expect(effects).toBeNull();
|
||||
|
||||
expect(global.window.addEventListener).not.toHaveBeenCalled();
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
it('cleans up event listeners on unmount', () => {
|
||||
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
||||
useIFrameBehavior(props);
|
||||
|
||||
const effects = getEffects([true, props.elementId], React);
|
||||
const cleanup = effects[0](); // Execute the effect and get the cleanup function.
|
||||
cleanup(); // Call the cleanup function.
|
||||
|
||||
expect(global.window.removeEventListener).toHaveBeenCalledTimes(2);
|
||||
expect(global.window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
expect(global.window.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
describe('handleIFrameLoad', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
@@ -23,6 +24,7 @@ const Unit = ({
|
||||
id,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { authenticatedUser } = React.useContext(AppContext);
|
||||
const examAccess = useExamAccess({ id });
|
||||
const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id });
|
||||
@@ -35,6 +37,7 @@ const Unit = ({
|
||||
view,
|
||||
format,
|
||||
examAccess,
|
||||
jumpToId: searchParams.get('jumpToId'),
|
||||
}));
|
||||
|
||||
const iframeUrl = getUrl();
|
||||
@@ -60,6 +63,7 @@ const Unit = ({
|
||||
onLoaded={onLoaded}
|
||||
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
|
||||
title={unit.title}
|
||||
courseId={courseId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { when } from 'jest-when';
|
||||
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
|
||||
@@ -14,6 +15,7 @@ import { modelKeys, views } from './constants';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('./hooks', () => ({ useUnitData: jest.fn() }));
|
||||
jest.mock('react-router-dom');
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const utils = jest.requireActual('@edx/react-unit-test-utils/dist');
|
||||
@@ -82,7 +84,11 @@ when(useModel)
|
||||
|
||||
let el;
|
||||
describe('Unit component', () => {
|
||||
const searchParams = { get: (prop) => prop };
|
||||
const setSearchParams = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useSearchParams.mockImplementation(() => [searchParams, setSearchParams]);
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<Unit {...props} />);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { stringify } from 'query-string';
|
||||
import { stringifyUrl } from 'query-string';
|
||||
|
||||
export const iframeParams = {
|
||||
show_title: 0,
|
||||
@@ -12,15 +12,20 @@ export const getIFrameUrl = ({
|
||||
view,
|
||||
format,
|
||||
examAccess,
|
||||
jumpToId,
|
||||
}) => {
|
||||
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
|
||||
const params = stringify({
|
||||
...iframeParams,
|
||||
view,
|
||||
...(format && { format }),
|
||||
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
|
||||
return stringifyUrl({
|
||||
url: xblockUrl,
|
||||
query: {
|
||||
...iframeParams,
|
||||
view,
|
||||
...(format && { format }),
|
||||
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
|
||||
jumpToId, // Pass jumpToId as query param as fragmentIdentifier is not passed to server.
|
||||
},
|
||||
fragmentIdentifier: jumpToId, // this is used by browser to scroll to correct block.
|
||||
});
|
||||
return `${xblockUrl}?${params}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { stringify } from 'query-string';
|
||||
import { stringifyUrl } 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 })),
|
||||
stringifyUrl: jest.fn((arg) => ({ stringifyUrl: arg })),
|
||||
}));
|
||||
|
||||
const config = { LMS_BASE_URL: 'test-lms-url' };
|
||||
@@ -21,41 +21,43 @@ const props = {
|
||||
|
||||
describe('urls module getIFrameUrl', () => {
|
||||
test('format provided, exam access and token available', () => {
|
||||
const params = stringify({
|
||||
...iframeParams,
|
||||
view: props.view,
|
||||
format: props.format,
|
||||
exam_access: props.examAccess.accessToken,
|
||||
const url = stringifyUrl({
|
||||
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
|
||||
query: {
|
||||
...iframeParams,
|
||||
view: props.view,
|
||||
format: props.format,
|
||||
exam_access: props.examAccess.accessToken,
|
||||
},
|
||||
});
|
||||
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
|
||||
expect(getIFrameUrl(props)).toEqual(url);
|
||||
});
|
||||
test('no format provided, exam access blocked', () => {
|
||||
const params = stringify({ ...iframeParams, view: props.view });
|
||||
const url = stringifyUrl({
|
||||
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
|
||||
query: { ...iframeParams, view: props.view },
|
||||
});
|
||||
expect(getIFrameUrl({
|
||||
id: props.id,
|
||||
view: props.view,
|
||||
examAccess: { blockAccess: true },
|
||||
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
|
||||
})).toEqual(url);
|
||||
});
|
||||
test('src and dest languages provided', () => {
|
||||
const params = stringify({
|
||||
...iframeParams,
|
||||
view: props.view,
|
||||
src_lang: 'test-src-lang',
|
||||
dest_lang: 'test-dest-lang',
|
||||
test('jumpToId and fragmentIdentifier is added to url', () => {
|
||||
const url = stringifyUrl({
|
||||
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
|
||||
query: {
|
||||
...iframeParams,
|
||||
view: props.view,
|
||||
format: props.format,
|
||||
exam_access: props.examAccess.accessToken,
|
||||
jumpToId: 'some-xblock-id',
|
||||
},
|
||||
fragmentIdentifier: 'some-xblock-id',
|
||||
});
|
||||
expect(getIFrameUrl({
|
||||
...props,
|
||||
srcLanguage: 'test-src-lang',
|
||||
destLanguage: 'test-dest-lang',
|
||||
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
|
||||
});
|
||||
test('src and dest languages provided are the same', () => {
|
||||
const params = stringify({ ...iframeParams, view: props.view });
|
||||
expect(getIFrameUrl({
|
||||
...props,
|
||||
srcLanguage: 'test-lang',
|
||||
destLanguage: 'test-lang',
|
||||
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
|
||||
jumpToId: 'some-xblock-id',
|
||||
})).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,50 @@ describe('Unit Navigation', () => {
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('has the "Next" button disabled for entrance exam failed', async () => {
|
||||
const testCourseMetadata = {
|
||||
...courseMetadata,
|
||||
certificate_data: { cert_status: 'bogus_status' },
|
||||
enrollment: { is_active: true },
|
||||
entrance_exam_data: {
|
||||
entrance_exam_current_score: 0, entrance_exam_enabled: true, entrance_exam_id: '1', entrance_exam_minimum_score_pct: 0.65, entrance_exam_passed: false,
|
||||
},
|
||||
};
|
||||
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
|
||||
// Have to refetch the sequenceId since the new store generates new sequences
|
||||
const { courseware } = testStore.getState();
|
||||
const testData = { ...mockData, sequenceId: courseware.sequenceId };
|
||||
|
||||
render(
|
||||
<UnitNavigation {...testData} unitId={unitBlocks[0].id} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('has the "Next" button enabled for entrance exam pass', async () => {
|
||||
const testCourseMetadata = {
|
||||
...courseMetadata,
|
||||
certificate_data: { cert_status: 'bogus_status' },
|
||||
enrollment: { is_active: true },
|
||||
entrance_exam_data: {
|
||||
entrance_exam_current_score: 1.0, entrance_exam_enabled: true, entrance_exam_id: '1', entrance_exam_minimum_score_pct: 0.65, entrance_exam_passed: true,
|
||||
},
|
||||
};
|
||||
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
|
||||
// Have to refetch the sequenceId since the new store generates new sequences
|
||||
const { courseware } = testStore.getState();
|
||||
const testData = { ...mockData, sequenceId: courseware.sequenceId };
|
||||
|
||||
render(
|
||||
<UnitNavigation {...testData} unitId={unitBlocks[0].id} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays end of course message instead of the "Next" button as needed', async () => {
|
||||
const testCourseMetadata = { ...courseMetadata, certificate_data: { cert_status: 'notpassing' }, enrollment: { is_active: true } };
|
||||
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
|
||||
const sequence = useModel('sequences', currentSequenceId);
|
||||
const courseId = useSelector(state => state.courseware.courseId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const { entranceExamData: { entranceExamPassed } } = useModel('coursewareMeta', courseId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
|
||||
// If we don't know the sequence and unit yet, then assume no.
|
||||
@@ -25,6 +26,16 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
|
||||
};
|
||||
}
|
||||
|
||||
// if entrance exam is not passed then we should treat this as 1st and last unit
|
||||
if (entranceExamPassed === false) {
|
||||
return {
|
||||
isFirstUnit: true,
|
||||
isLastUnit: true,
|
||||
navigationDisabledNextSequence: false,
|
||||
navigationDisabledPrevSequence: false,
|
||||
};
|
||||
}
|
||||
|
||||
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
||||
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.discussions-sidebar-frame {
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "xl")) {
|
||||
max-height: calc(100vh - 65px);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const DiscussionsSidebar = ({ intl }) => {
|
||||
>
|
||||
<iframe
|
||||
src={`${discussionsUrl}?inContextSidebar`}
|
||||
className="d-flex sticky-top vh-100 w-100 border-0"
|
||||
className="d-flex sticky-top vh-100 w-100 border-0 discussions-sidebar-frame"
|
||||
title={intl.formatMessage(messages.discussionsTitle)}
|
||||
allow="clipboard-write"
|
||||
loading="lazy"
|
||||
|
||||
@@ -129,24 +129,6 @@ describe('NotificationTray', () => {
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks notification as seen 3 seconds later', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onNotificationSeen = jest.fn();
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
onNotificationSeen,
|
||||
}}
|
||||
>
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(0);
|
||||
jest.advanceTimersByTime(3000);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders notification tray with full screen "Back to course" at responsive view', async () => {
|
||||
global.innerWidth = breakpoints.medium.maxWidth;
|
||||
const toggleNotificationTray = jest.fn();
|
||||
@@ -170,4 +152,20 @@ describe('NotificationTray', () => {
|
||||
expect(toggleNotificationTray)
|
||||
.toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks notification as seen 3 seconds later', async () => {
|
||||
const onNotificationSeen = jest.fn();
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
onNotificationSeen,
|
||||
}}
|
||||
>
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(0);
|
||||
await waitFor(() => expect(onNotificationSeen).toHaveBeenCalledTimes(1), { timeout: 3500 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import {
|
||||
APP_INIT_ERROR, APP_READY, subscribe, initialize,
|
||||
mergeConfig,
|
||||
@@ -37,6 +34,7 @@ import LiveTab from './course-home/live-tab/LiveTab';
|
||||
import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
|
||||
import DecodePageRoute from './decode-page-route';
|
||||
import { DECODE_ROUTES, ROUTES } from './constants';
|
||||
import PreferencesUnsubscribe from './preferences-unsubscribe';
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
@@ -50,6 +48,7 @@ subscribe(APP_READY, () => {
|
||||
<Routes>
|
||||
<Route path={ROUTES.UNSUBSCRIBE} element={<PageWrap><GoalUnsubscribe /></PageWrap>} />
|
||||
<Route path={ROUTES.REDIRECT} element={<PageWrap><CoursewareRedirectLandingPage /></PageWrap>} />
|
||||
<Route path={ROUTES.PREFERENCES_UNSUBSCRIBE} element={<PageWrap><PreferencesUnsubscribe /></PageWrap>} />
|
||||
<Route
|
||||
path={DECODE_ROUTES.ACCESS_DENIED}
|
||||
element={<DecodePageRoute><CourseAccessErrorPage /></DecodePageRoute>}
|
||||
|
||||
@@ -430,8 +430,23 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.min-height-700 {
|
||||
min-height: 700px;
|
||||
}
|
||||
|
||||
.size-4r {
|
||||
width: 4rem !important;
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
||||
.size-56px {
|
||||
width: 56px !important;
|
||||
height: 56px !important;
|
||||
}
|
||||
|
||||
// Import component-specific sass files
|
||||
@import "courseware/course/celebration/CelebrationModal.scss";
|
||||
@import "courseware/course/sidebar/sidebars/discussions/Discussions.scss";
|
||||
@import "courseware/course/sidebar/sidebars/notifications/NotificationIcon.scss";
|
||||
@import "courseware/course/sequence/lock-paywall/LockPaywall.scss";
|
||||
@import "shared/streak-celebration/StreakCelebrationModal.scss";
|
||||
|
||||
@@ -22,7 +22,8 @@ class MasqueradeWidget extends Component {
|
||||
this.state = {
|
||||
autoFocus: false,
|
||||
masquerade: 'Staff',
|
||||
options: [],
|
||||
active: {},
|
||||
available: [],
|
||||
shouldShowUserNameInput: false,
|
||||
masqueradeUsername: null,
|
||||
};
|
||||
@@ -58,21 +59,42 @@ class MasqueradeWidget extends Component {
|
||||
}
|
||||
|
||||
onSuccess(data) {
|
||||
const options = this.parseAvailableOptions(data);
|
||||
const { active, available } = this.parseAvailableOptions(data);
|
||||
this.setState({
|
||||
options,
|
||||
active,
|
||||
available,
|
||||
});
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
const options = this.state.available.map((group) => (
|
||||
<MasqueradeWidgetOption
|
||||
groupId={group.groupId}
|
||||
groupName={group.name}
|
||||
key={group.name}
|
||||
role={group.role}
|
||||
selected={this.state.active}
|
||||
userName={group.userName}
|
||||
userPartitionId={group.userPartitionId}
|
||||
userNameInputToggle={(...args) => this.toggle(...args)}
|
||||
onSubmit={(payload) => this.onSubmit(payload)}
|
||||
/>
|
||||
));
|
||||
return options;
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.props.onError('');
|
||||
}
|
||||
|
||||
toggle(show) {
|
||||
toggle(show, groupId, groupName, role, userName, userPartitionId) {
|
||||
this.setState(prevState => ({
|
||||
autoFocus: true,
|
||||
masquerade: 'Specific Student...',
|
||||
masquerade: groupName,
|
||||
shouldShowUserNameInput: show === undefined ? !prevState.shouldShowUserNameInput : show,
|
||||
active: {
|
||||
...prevState.active, groupId, role, userName, userPartitionId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -80,19 +102,6 @@ class MasqueradeWidget extends Component {
|
||||
const data = postData || {};
|
||||
const active = data.active || {};
|
||||
const available = data.available || [];
|
||||
const options = available.map((group) => (
|
||||
<MasqueradeWidgetOption
|
||||
groupId={group.groupId}
|
||||
groupName={group.name}
|
||||
key={group.name}
|
||||
role={group.role}
|
||||
selected={active}
|
||||
userName={group.userName}
|
||||
userPartitionId={group.userPartitionId}
|
||||
userNameInputToggle={(...args) => this.toggle(...args)}
|
||||
onSubmit={(payload) => this.onSubmit(payload)}
|
||||
/>
|
||||
));
|
||||
if (active.userName) {
|
||||
this.setState({
|
||||
autoFocus: false,
|
||||
@@ -105,14 +114,13 @@ class MasqueradeWidget extends Component {
|
||||
} else if (active.role === 'student') {
|
||||
this.setState({ masquerade: 'Learner' });
|
||||
}
|
||||
return options;
|
||||
return { active, available };
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
autoFocus,
|
||||
masquerade,
|
||||
options,
|
||||
shouldShowUserNameInput,
|
||||
masqueradeUsername,
|
||||
} = this.state;
|
||||
@@ -126,7 +134,7 @@ class MasqueradeWidget extends Component {
|
||||
{masquerade}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{options}
|
||||
{this.getOptions()}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@@ -135,7 +143,7 @@ class MasqueradeWidget extends Component {
|
||||
<span className="col-auto col-form-label pl-3" id="masquerade-search-label">{`${specificLearnerInputText}:`}</span>
|
||||
<MasqueradeUserNameInput
|
||||
id="masquerade-search"
|
||||
className="col-4 form-control"
|
||||
className="col-4"
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={masqueradeUsername}
|
||||
onError={(errorMessage) => this.onError(errorMessage)}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { getAllByRole } from '@testing-library/dom';
|
||||
import { act } from '@testing-library/react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MasqueradeWidget from './MasqueradeWidget';
|
||||
import {
|
||||
render, screen, fireEvent, initializeTestStore, waitFor, logUnhandledRequests,
|
||||
} from '../../setupTest';
|
||||
|
||||
const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform'),
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
getConfig.mockImplementation(() => originalConfig);
|
||||
|
||||
describe('Masquerade Widget Dropdown', () => {
|
||||
let mockData;
|
||||
let courseware;
|
||||
let mockResponse;
|
||||
let axiosMock;
|
||||
let masqueradeUrl;
|
||||
const masqueradeOptions = [
|
||||
{
|
||||
name: 'Staff',
|
||||
role: 'staff',
|
||||
},
|
||||
{
|
||||
name: 'Specific Student...',
|
||||
role: 'student',
|
||||
user_name: '',
|
||||
},
|
||||
{
|
||||
group_id: 1,
|
||||
name: 'Audit',
|
||||
role: 'student',
|
||||
user_partition_id: 50,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
const store = await initializeTestStore();
|
||||
courseware = store.getState().courseware;
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseware.courseId}/masquerade`;
|
||||
mockData = {
|
||||
courseId: courseware.courseId,
|
||||
onError: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockResponse = {
|
||||
success: true,
|
||||
active: {
|
||||
course_key: courseware.courseId,
|
||||
group_id: null,
|
||||
role: 'staff',
|
||||
user_name: null,
|
||||
user_partition_id: null,
|
||||
group_name: null,
|
||||
},
|
||||
available: masqueradeOptions,
|
||||
};
|
||||
axiosMock.reset();
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, mockResponse);
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
|
||||
it('renders masquerade name correctly', async () => {
|
||||
render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Staff');
|
||||
});
|
||||
|
||||
masqueradeOptions.forEach((option) => {
|
||||
it(`marks role ${option.role} as active`, async () => {
|
||||
const active = {
|
||||
course_key: courseware.courseId,
|
||||
group_id: option.group_id ?? null,
|
||||
role: option.role,
|
||||
user_name: option.user_name ?? null,
|
||||
user_partition_id: option.user_partition_id ?? null,
|
||||
group_name: null,
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
success: true,
|
||||
active,
|
||||
available: masqueradeOptions,
|
||||
};
|
||||
|
||||
axiosMock.reset();
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, mockResponse);
|
||||
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
await act(async () => {
|
||||
await fireEvent.click(dropdownToggle);
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === option.name) {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
expect(button).not.toHaveClass('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the clicks with toggle', async () => {
|
||||
const { container } = render(<MasqueradeWidget {...mockData} />);
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
await act(async () => {
|
||||
await fireEvent.click(dropdownToggle);
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||
const studentOption = getAllByRole(dropdownMenu, 'button', { hidden: true }).filter(
|
||||
button => (button.textContent === 'Specific Student...'),
|
||||
)[0];
|
||||
await act(async () => {
|
||||
await fireEvent.click(studentOption);
|
||||
});
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === 'Specific Student...') {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
expect(button).not.toHaveClass('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ class MasqueradeWidgetOption extends Component {
|
||||
event.target.parentNode.parentNode.click();
|
||||
const {
|
||||
groupId,
|
||||
groupName,
|
||||
role,
|
||||
userName,
|
||||
userPartitionId,
|
||||
@@ -23,7 +24,7 @@ class MasqueradeWidgetOption extends Component {
|
||||
} = this.props;
|
||||
const payload = {};
|
||||
if (userName || userName === '') {
|
||||
userNameInputToggle(true);
|
||||
userNameInputToggle(true, groupId, groupName, role, userName, userPartitionId);
|
||||
return false;
|
||||
}
|
||||
if (role) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { getAllByRole } from '@testing-library/dom';
|
||||
import { act } from '@testing-library/react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import MasqueradeWidgetOption from './MasqueradeWidgetOption';
|
||||
import {
|
||||
render, fireEvent, initializeTestStore,
|
||||
} from '../../setupTest';
|
||||
|
||||
const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform'),
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
getConfig.mockImplementation(() => originalConfig);
|
||||
|
||||
describe('Masquerade Widget Dropdown', () => {
|
||||
let courseware;
|
||||
let mockDataStaff;
|
||||
let mockDataStudent;
|
||||
let active;
|
||||
|
||||
beforeAll(async () => {
|
||||
const store = await initializeTestStore();
|
||||
courseware = store.getState().courseware;
|
||||
active = {
|
||||
courseKey: courseware.courseId,
|
||||
groupId: null,
|
||||
role: 'staff',
|
||||
userName: null,
|
||||
userPartitionId: null,
|
||||
groupName: null,
|
||||
};
|
||||
mockDataStaff = {
|
||||
groupId: null,
|
||||
groupName: 'Staff',
|
||||
key: 'Staff',
|
||||
role: 'staff',
|
||||
selected: active,
|
||||
userName: null,
|
||||
userPartitionId: null,
|
||||
userNameInputToggle: () => {},
|
||||
onSubmit: () => {},
|
||||
};
|
||||
mockDataStudent = {
|
||||
groupId: null,
|
||||
groupName: 'Specific Student...',
|
||||
key: 'Specific Student...',
|
||||
role: 'student',
|
||||
selected: active,
|
||||
userName: '',
|
||||
userPartitionId: null,
|
||||
userNameInputToggle: () => {},
|
||||
onSubmit: () => {},
|
||||
};
|
||||
Object.defineProperty(global, 'location', {
|
||||
configurable: true,
|
||||
value: { reload: jest.fn() },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders masquerade active option correctly', async () => {
|
||||
const { container } = render(<MasqueradeWidgetOption {...mockDataStaff} />);
|
||||
const button = getAllByRole(container, 'button', { hidden: true })[0];
|
||||
expect(button).toHaveTextContent('Staff');
|
||||
expect(button).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('renders masquerade inactive option correctly', async () => {
|
||||
const { container } = render(<MasqueradeWidgetOption {...mockDataStudent} />);
|
||||
const button = getAllByRole(container, 'button', { hidden: true })[0];
|
||||
expect(button).toHaveTextContent('Specific Student...');
|
||||
expect(button).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('handles the clicks regular option', () => {
|
||||
const onSubmit = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
const { container } = render(<MasqueradeWidgetOption {...mockDataStaff} onSubmit={onSubmit} />);
|
||||
const button = getAllByRole(container, 'button', { hidden: true })[0];
|
||||
act(() => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles the clicks student option', () => {
|
||||
const userNameInputToggle = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
const { container } = render(
|
||||
<MasqueradeWidgetOption {...mockDataStudent} userNameInputToggle={userNameInputToggle} />,
|
||||
);
|
||||
const button = getAllByRole(container, 'button', { hidden: true })[0];
|
||||
act(() => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
expect(userNameInputToggle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
11
src/preferences-unsubscribe/data/api.js
Normal file
11
src/preferences-unsubscribe/data/api.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export const getUnsubscribeUrl = (userToken, updatePatch) => (
|
||||
`${getConfig().LMS_BASE_URL}/api/notifications/preferences/update/${userToken}/${updatePatch}/`
|
||||
);
|
||||
|
||||
export async function unsubscribeNotificationPreferences(userToken, updatePatch) {
|
||||
const url = getUnsubscribeUrl(userToken, updatePatch);
|
||||
return getAuthenticatedHttpClient().get(url);
|
||||
}
|
||||
89
src/preferences-unsubscribe/index.jsx
Normal file
89
src/preferences-unsubscribe/index.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Container, Icon, Hyperlink } from '@openedx/paragon';
|
||||
import { CheckCircleLightOutline, ErrorOutline } from '@openedx/paragon/icons';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import Header from '@edx/frontend-component-header';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { LOADED, LOADING, FAILED } from '../constants';
|
||||
import PageLoading from '../generic/PageLoading';
|
||||
import { unsubscribeNotificationPreferences } from './data/api';
|
||||
import messages from './messages';
|
||||
|
||||
const PreferencesUnsubscribe = () => {
|
||||
const intl = useIntl();
|
||||
const { userToken, updatePatch } = useParams();
|
||||
const [status, setStatus] = useState(LOADING);
|
||||
|
||||
useEffect(() => {
|
||||
unsubscribeNotificationPreferences(userToken, updatePatch).then(
|
||||
() => setStatus(LOADED),
|
||||
(error) => {
|
||||
setStatus(FAILED);
|
||||
logError(error);
|
||||
},
|
||||
);
|
||||
sendTrackEvent('edx.ui.lms.notifications.preferences.unsubscribe', { userToken, updatePatch });
|
||||
}, []);
|
||||
|
||||
const pageContent = {
|
||||
icon: CheckCircleLightOutline,
|
||||
iconClass: 'text-success',
|
||||
headingText: messages.unsubscribeSuccessHeading,
|
||||
bodyText: messages.unsubscribeSuccessMessage,
|
||||
};
|
||||
|
||||
if (status === FAILED) {
|
||||
pageContent.icon = ErrorOutline;
|
||||
pageContent.iconClass = 'text-danger';
|
||||
pageContent.headingText = messages.unsubscribeFailedHeading;
|
||||
pageContent.bodyText = messages.unsubscribeFailedMessage;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh' }}>
|
||||
<Header />
|
||||
<Container size="xs" className="h-75 mx-auto my-auto">
|
||||
<div className="d-flex flex-row h-100">
|
||||
<div className="mx-auto my-auto">
|
||||
{status === LOADING && <PageLoading srMessage={`${intl.formatMessage(messages.unsubscribeLoading)}`} />}
|
||||
{status !== LOADING && (
|
||||
<>
|
||||
<Icon src={pageContent.icon} className={`size-56px mx-auto ${pageContent.iconClass}`} />
|
||||
<h3 className="font-weight-bold text-primary-500 text-center my-3" data-testid="heading-text">
|
||||
{intl.formatMessage(pageContent.headingText)}
|
||||
</h3>
|
||||
<div className="font-weight-normal text-gray-700 text-center">
|
||||
{intl.formatMessage(pageContent.bodyText)}
|
||||
</div>
|
||||
<small className="d-block font-weight-normal text-gray text-center mt-3">
|
||||
<FormattedMessage
|
||||
id="learning.notification.preferences.unsubscribe.preferenceCenterUrl"
|
||||
description="Shown as a suggestion or recommendation for learner when their unsubscribing request has failed"
|
||||
defaultMessage="Go to the {preferenceCenterUrl} to set your preferences"
|
||||
values={{
|
||||
preferenceCenterUrl: (
|
||||
<Hyperlink
|
||||
destination={`${getConfig().ACCOUNT_SETTINGS_URL}/notifications`}
|
||||
>
|
||||
{intl.formatMessage(messages.preferenceCenterUrl)}
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesUnsubscribe;
|
||||
75
src/preferences-unsubscribe/index.test.jsx
Normal file
75
src/preferences-unsubscribe/index.test.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ROUTES } from '../constants';
|
||||
import {
|
||||
initializeTestStore, initializeMockApp, render, screen, waitFor,
|
||||
} from '../setupTest';
|
||||
import { getUnsubscribeUrl } from './data/api';
|
||||
import PreferencesUnsubscribe from './index';
|
||||
import initializeStore from '../store';
|
||||
import { UserMessagesProvider } from '../generic/user-messages';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('Notification Preferences One Click Unsubscribe', () => {
|
||||
let axiosMock;
|
||||
let component;
|
||||
let store;
|
||||
const userToken = '1234';
|
||||
const updatePatch = 'abc123';
|
||||
const url = getUnsubscribeUrl(userToken, updatePatch);
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendTrackEvent.mockClear();
|
||||
axiosMock.reset();
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<UserMessagesProvider>
|
||||
<MemoryRouter initialEntries={[`${`/preferences-unsubscribe/${userToken}/${updatePatch}/`}`]}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.PREFERENCES_UNSUBSCRIBE} element={<PreferencesUnsubscribe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
});
|
||||
|
||||
it('tests UI when unsubscribe is successful', async () => {
|
||||
axiosMock.onGet(url).reply(200, { result: 'success' });
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(screen.getByTestId('heading-text')).toHaveTextContent('Unsubscribe successful');
|
||||
});
|
||||
|
||||
it('tests UI when unsubscribe failed', async () => {
|
||||
axiosMock.onGet(url).reply(400, { result: 'failed' });
|
||||
render(component);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('heading-text')).toHaveTextContent('Error unsubscribing from preference');
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.notifications.preferences.unsubscribe', {
|
||||
userToken,
|
||||
updatePatch,
|
||||
});
|
||||
});
|
||||
});
|
||||
30
src/preferences-unsubscribe/messages.js
Normal file
30
src/preferences-unsubscribe/messages.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
unsubscribeLoading: {
|
||||
id: 'learning.notification.preferences.unsubscribe.loading',
|
||||
defaultMessage: 'Loading',
|
||||
},
|
||||
unsubscribeSuccessHeading: {
|
||||
id: 'learning.notification.preferences.unsubscribe.successHeading',
|
||||
defaultMessage: 'Unsubscribe successful',
|
||||
},
|
||||
unsubscribeSuccessMessage: {
|
||||
id: 'learning.notification.preferences.unsubscribe.successMessage',
|
||||
defaultMessage: 'You have successfully unsubscribed from email digests for learning activity',
|
||||
},
|
||||
unsubscribeFailedHeading: {
|
||||
id: 'learning.notification.preferences.unsubscribe.failedHeading',
|
||||
defaultMessage: 'Error unsubscribing from preference',
|
||||
},
|
||||
unsubscribeFailedMessage: {
|
||||
id: 'learning.notification.preferences.unsubscribe.failedMessage',
|
||||
defaultMessage: 'Invalid Url or token expired',
|
||||
},
|
||||
preferenceCenterUrl: {
|
||||
id: 'learning.notification.preferences.unsubscribe.preferenceCenterUrl',
|
||||
defaultMessage: 'preferences page',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import './courseware/data/__factories__';
|
||||
|
||||
Reference in New Issue
Block a user