Compare commits

...

50 Commits

Author SHA1 Message Date
dependabot[bot]
a853f8ce0c build(deps): bump nth-check and truncate-html
Bumps [nth-check](https://github.com/fb55/nth-check) to 2.1.1 and updates ancestor dependency [truncate-html](https://github.com/oe/truncate-html). These dependencies need to be updated together.


Updates `nth-check` from 1.0.2 to 2.1.1
- [Release notes](https://github.com/fb55/nth-check/releases)
- [Commits](https://github.com/fb55/nth-check/compare/v1.0.2...v2.1.1)

Updates `truncate-html` from 1.0.4 to 1.1.1
- [Release notes](https://github.com/oe/truncate-html/releases)
- [Commits](https://github.com/oe/truncate-html/compare/v1.0.4...v1.1.1)

---
updated-dependencies:
- dependency-name: nth-check
  dependency-type: indirect
- dependency-name: truncate-html
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-03 21:37:33 +00:00
Braden MacDonald
d64a4e448b chore(deps): bump sass to v1.79 and sass-loader to v16 (#1490) 2024-10-03 14:36:01 -07:00
Piotr Surowiec
860b3f9952 fix: send XBlock visibility status to the LMS (#1491) 2024-10-01 20:38:19 +05:30
edX requirements bot
4418c5422f chore: update browserslist DB (#1493)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-09-30 00:36:11 +00:00
dependabot[bot]
372c9de1db chore(deps): bump micromatch from 4.0.5 to 4.0.8 (#1469)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-23 12:14:18 -07:00
dependabot[bot]
65adaf18d4 build(deps): bump webpack from 5.89.0 to 5.94.0 (#1468)
Bumps [webpack](https://github.com/webpack/webpack) from 5.89.0 to 5.94.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.89.0...v5.94.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-23 19:11:35 +00:00
renovate[bot]
ed77465282 chore(deps): update dependency joi to v17.13.3 (#1474) 2024-09-23 12:04:09 -07:00
edX requirements bot
f5f6747ecb chore: update browserslist DB (#1489)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-09-23 11:42:30 -07:00
Ishan Masdekar
fbe16483ac fix: corrects navigation if the student does not pass the entrance exam (#1429) 2024-09-23 11:41:52 -07:00
Piotr Surowiec
e4a0105042 fix: increase subsection grades rounding precision (#1397)
We used two decimal digits to match the experience from the edx-platform.
However, https://github.com/openedx/edx-platform/pull/27788 increased
the precision to reduce the impact of double rounding.
2024-09-23 11:39:15 -07:00
Kaustav Banerjee
1d19ae0e7b fix: masquerade dropdown not showing current selection (#1434)
* feat: remove child components from state and use data instead

* fix: change active selection based on user input

* test: add test cases
2024-09-23 11:32:41 -07:00
Navin Karkera
b9d11982e3 feat: support jumping to specific xblock id (#1427)
Adds ability to pass `jumpToId` query param to iframe url as id hash to
be used by browser to scroll to the correct xblock
2024-09-20 10:34:56 -07:00
Braden MacDonald
73590f1ccd fix: upload codecov report as a separate workflow step (#1476) 2024-09-20 10:21:33 -07:00
Braden MacDonald
f8d35bf45d docs: Update README to explain how to run this using Tutor (#1472) 2024-09-19 09:55:48 -07:00
Braden MacDonald
2038bad822 feat: use bundlewatch to monitor bundle size (#1479) 2024-09-19 16:15:15 +00:00
Braden MacDonald
6c11947397 fix: sass deprecation warning in GradeBar.scss (#1473) 2024-09-19 09:06:11 -07:00
renovate[bot]
26565cd89c chore(deps): update dependency axios-mock-adapter to v2 (#1484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-17 11:28:34 -07:00
edX requirements bot
3a203e8351 chore: update browserslist DB (#1487)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-09-16 09:48:48 -07:00
Kristin Aoki
500e4abcb9 Revert "fix: show studio button if user has access (#1452)" (#1486)
This reverts commit 82b27e59cc.
2024-09-13 13:39:10 -04:00
renovate[bot]
d78851bb5b chore(deps): update dependency @pact-foundation/pact to v13 (#1478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-12 22:12:41 +00:00
renovate[bot]
8c9a43d02b chore(deps): update actions/checkout action to v4 (#1477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-12 15:06:53 -07:00
renovate[bot]
13c7c1de89 fix(deps): update dependency classnames to v2.5.1 (#1464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-12 13:55:42 -07:00
renovate[bot]
ba44b28cec fix(deps): update dependency @openedx/paragon to v22.8.1 2024-09-12 19:18:16 +00:00
renovate[bot]
79affe0629 chore(deps): update dependency axios-mock-adapter to v1.22.0 (#1037)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-12 19:10:17 +00:00
edX requirements bot
d0ec7e3fb2 chore: enable github action auto update in dependabot.yml (#1451) 2024-09-12 11:48:37 -07:00
renovate[bot]
3f8b8077a9 chore(deps): update dependency @testing-library/jest-dom to v5.17.0 (#1362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-12 11:47:07 -07:00
renovate[bot]
290f17d76d fix(deps): update dependency @openedx/frontend-plugin-framework to v1.3.0 2024-09-12 18:46:31 +00:00
renovate[bot]
64a1149550 fix(deps): update dependency @edx/frontend-platform to v8.1.1 2024-09-12 18:46:16 +00:00
edX requirements bot
e907ade40a chore: update browserslist DB (#1447)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-09-12 11:38:51 -07:00
renovate[bot]
68a7bf5527 fix(deps): update dependency @openedx/frontend-slot-footer to v1.0.5 2024-09-12 16:21:21 +00:00
renovate[bot]
db75ea28e4 fix(deps): update dependency @edx/openedx-atlas to v0.6.2 2024-09-12 16:20:58 +00:00
Braden MacDonald
ba4bdfe6af fix: remove unneeded deps, put build deps into 'dependencies' not dev (#1456) 2024-09-12 09:14:56 -07:00
Jorg Are
b63508db97 feat: Add a plugin slot for the content iframe loader (#1453) 2024-09-11 14:11:08 +01:00
Kristin Aoki
82b27e59cc fix: show studio button if user has access (#1452) 2024-09-09 09:11:56 -04:00
Bilal Qamar
ec8b5c5d6e build: Upgrade to Node 20 (#1443)
* feat: updated node to v20

* fix: updated/resolved failing test

* refactor: updated package-lock along with validate & lockfile version workflows

* refactor: removed unnecesary eslint ignore & updated ProductTours test
2024-09-06 12:23:07 -04:00
Bilal Qamar
dc1e9cd2e8 test: Add Node 20 to CI matrix (#1445)
* test: Add Node 20 to CI matrix

* refactor: removed eslint disable

* refactor: updated validate workflow continue-on-error node version
2024-09-03 12:20:36 -04:00
Muhammad Adeel Tajamul
a681333a08 feat: added UI for one click unsubscribe flow (#1444)
* feat: added UI for one click unsubscribe flow

---------

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2024-08-19 14:15:15 +05:00
renovate[bot]
7cbbc720d1 chore(deps): update dependency @openedx/frontend-build to v14.1.0 2024-08-15 03:10:59 +00:00
renovate[bot]
863a838e6e fix(deps): update dependency @openedx/frontend-slot-footer to v1.0.3 2024-08-15 00:29:50 +00:00
renovate[bot]
5b046e828a fix(deps): update dependency @openedx/frontend-plugin-framework to v1.2.3 2024-08-14 22:00:05 +00:00
renovate[bot]
a8f72c5e75 fix(deps): update dependency @edx/openedx-atlas to v0.6.1 2024-08-14 20:28:08 +00:00
renovate[bot]
bb6c678904 fix(deps): update dependency @edx/frontend-component-header to v5.3.4 2024-08-14 15:27:41 +00:00
Bilal Qamar
71c2a31531 feat: updated frontend-build & frontend-platform major versions (#1391)
* feat: platform & react-unit-test-utils major version update, updated jest to v29

* feat: updated frontend-build to v14 along with respective edx packages

* refactor: bumped package versions, updated snapshots for failing tests

* fix: code refactors to resolve failing tests

* refactor: added code comment in jest config
2024-08-14 11:20:27 -04:00
Braden MacDonald
99a44dda37 docs: transfer maintainership from 2U Aurora to volunteers (Braden + Farhaan) 2024-08-08 08:27:24 -04:00
Marcos Rigoli
5ae86465cc fix: Optimizely initialization refactor for Xpert (#1432) 2024-08-06 14:01:12 -03:00
Marcos Rigoli
6e9c105eb9 fix: Updated learning assistant version (#1431) 2024-08-05 10:10:16 -04:00
sundasnoreen12
26199fa954 fix: reset initialsidebar after shouldDisplaySidebarOpen (#1428)
* fix: reset initialsidebar after shouldDisplaySidebarOpen

* fix: fixed issue due to localstorage sidebar value when shifting from old to new one
2024-08-02 15:22:18 +05:00
Arunmozhi
3a542766d7 fix: remove redundant form-control in masquerade user input 2024-08-01 11:18:57 -04:00
sundasnoreen12
bbe03dc46f fix: fixed overflow issue of stacked bar on mobile (#1425)
* fix: fixed overflow issue of stacked bar on mobile

* refactor: instead of ismobileview i used shouldDisplayFullScreen
2024-07-30 13:53:12 +05:00
Ihor Romaniuk
167d51b596 fix: iframe height for discussions sidebar (#1393)
* fix: iframe height for discussions sidebar

* fix: increase adaptation brakepoint
2024-07-26 10:57:40 -04:00
51 changed files with 5345 additions and 10183 deletions

1
.env
View File

@@ -4,6 +4,7 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
APP_ID='learning'
BASE_URL=''
CONTACT_URL=''
CREDENTIALS_BASE_URL=''

View File

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

View File

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

@@ -0,0 +1,7 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

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

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
18
20

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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+$"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -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} />);
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
.discussions-sidebar-frame {
@media (max-width: -1 + map-get($grid-breakpoints, "xl")) {
max-height: calc(100vh - 65px);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');
}
});
});
});

View File

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

View File

@@ -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();
});
});

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

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

View 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,
});
});
});

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

View File

@@ -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__';