Compare commits
2 Commits
dependabot
...
abdullahwa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e504da84 | ||
|
|
26f53ccfbd |
3
.env
3
.env
@@ -4,7 +4,7 @@
|
||||
NODE_ENV='production'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
APP_ID='learning'
|
||||
AI_TRANSLATIONS_URL=''
|
||||
BASE_URL=''
|
||||
CONTACT_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
@@ -14,6 +14,7 @@ DISCOVERY_API_BASE_URL=''
|
||||
DISCUSSIONS_MFE_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NEW_SIDEBAR=''
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||
EXAMS_BASE_URL=''
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
NODE_ENV='development'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
APP_ID='learning'
|
||||
AI_TRANSLATIONS_URL='http://localhost:18760'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
@@ -14,6 +14,7 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NEW_SIDEBAR=''
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL=''
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
NODE_ENV='test'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
APP_ID='learning'
|
||||
AI_TRANSLATIONS_URL='http://localhost:18760'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
@@ -14,6 +14,7 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NEW_SIDEBAR=''
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
|
||||
@@ -3,5 +3,3 @@ dist/
|
||||
packages/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
env.config.jsx
|
||||
example.env.config.jsx
|
||||
|
||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@@ -1,7 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
|
||||
|
||||
32
.github/workflows/validate.yml
vendored
32
.github/workflows/validate.yml
vendored
@@ -9,35 +9,15 @@ 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: Download code coverage results
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
name: code-coverage-report-20
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
1
Makefile
1
Makefile
@@ -57,7 +57,6 @@ validate:
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
npm run bundlewatch
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
|
||||
120
README.rst
120
README.rst
@@ -1,110 +1,77 @@
|
||||
#####################
|
||||
frontend-app-learning
|
||||
#####################
|
||||
|
||||
|codecov| |license|
|
||||
|
||||
********
|
||||
Purpose
|
||||
*******
|
||||
********
|
||||
|
||||
This is the Learning MFE (micro-frontend application), which renders all
|
||||
learner-facing course pages (like the course outline, the progress page,
|
||||
actual course content, etc).
|
||||
|
||||
Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
|
||||
|
||||
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
|
||||
:target: https://codecov.io/gh/edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
||||
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
|
||||
|
||||
***************
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
`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.
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
|
||||
|
||||
Cloning and Setup
|
||||
=================
|
||||
- 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.
|
||||
|
||||
1. Clone your new repo:
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
.. code-block:: bash
|
||||
.. code-block::
|
||||
|
||||
git clone https://github.com/openedx/frontend-app-learning.git
|
||||
1. Clone your new repo:
|
||||
|
||||
2. Use node v20.x.
|
||||
``git clone https://github.com/openedx/frontend-app-learning.git``
|
||||
|
||||
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>`_.
|
||||
2. Use node v18.x.
|
||||
|
||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
|
||||
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:
|
||||
3. Install npm dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
``cd frontend-app-learning && npm ci``
|
||||
|
||||
tutor mounts add /path/to/frontend-app-learning
|
||||
4. Start the dev server:
|
||||
|
||||
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
|
||||
|
||||
2. Start the dev server:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
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
|
||||
``npm start``
|
||||
|
||||
Local module development
|
||||
=========================
|
||||
|
||||
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
|
||||
file (which is git-ignored) that defines where to find your local modules, for instance:
|
||||
|
||||
.. code-block:: js
|
||||
file (which is git-ignored) that defines where to find your local modules, for instance::
|
||||
|
||||
module.exports = {
|
||||
/*
|
||||
@@ -133,14 +100,8 @@ The Learning MFE is similar to all the other Open edX MFEs. Read the Open
|
||||
edX Developer Guide's section on
|
||||
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
|
||||
|
||||
Plugins
|
||||
=======
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Environment Variables
|
||||
=====================
|
||||
======================
|
||||
|
||||
This MFE is configured via environment variables supplied at build time.
|
||||
All micro-frontends have a shared set of required environment variables,
|
||||
@@ -166,7 +127,7 @@ SOCIAL_UTM_MILESTONE_CAMPAIGN
|
||||
|
||||
SUPPORT_URL_CALCULATOR_MATH
|
||||
A link that explains how to use the in-course calculator. You can use the
|
||||
one in the example below if you don't want to have your own branded version.
|
||||
one in the example below, if you don't want to have your own branded version.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
|
||||
|
||||
@@ -179,7 +140,7 @@ SUPPORT_URL_ID_VERIFICATION
|
||||
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE
|
||||
A link that explains what a verified certificate is. You can use the
|
||||
one in the example below if you don't want to have your own branded version.
|
||||
one in the example below, if you don't want to have your own branded version.
|
||||
Optional.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
|
||||
@@ -195,13 +156,13 @@ TWITTER_URL
|
||||
A link to your Twitter account. The Twitter social-share link won't appear
|
||||
unless this is set. Optional.
|
||||
|
||||
Example: https://twitter.com/openedx
|
||||
Example: https://twitter.com/edXOnline
|
||||
|
||||
Getting Help
|
||||
============
|
||||
===========
|
||||
|
||||
If you're having trouble, we have `discussion forums`_
|
||||
where you can connect with others in the community.
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
@@ -219,18 +180,17 @@ For more information about these options, see the `Getting Help`_ page.
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
.. _discussion forums: https://discuss.openedx.org
|
||||
|
||||
Contributing
|
||||
============
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to discuss your new feature idea with the maintainers before
|
||||
to have a discussion about your new feature idea with the maintainers prior to
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
@@ -13,6 +13,6 @@ metadata:
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
spec:
|
||||
owner: group:committers-frontend-app-learning
|
||||
owner: group:2u-aurora
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import UnitTranslationPlugin from '@edx/unit-translation-selector-plugin';
|
||||
import UnitTranslationPlugin from '@plugins/UnitTranslationPlugin';
|
||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
// Load environment variables from .env file
|
||||
|
||||
@@ -9,12 +9,13 @@ 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',
|
||||
'@plugins/(.*)': '<rootDir>/plugins/$1',
|
||||
},
|
||||
testTimeout: 30000,
|
||||
globalSetup: "./global-setup.js",
|
||||
@@ -26,7 +27,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,
|
||||
|
||||
12386
package-lock.json
generated
12386
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
75
package.json
75
package.json
@@ -11,15 +11,12 @@
|
||||
],
|
||||
"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 .",
|
||||
"prepare": "husky install",
|
||||
"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",
|
||||
@@ -32,34 +29,31 @@
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@datadog/browser-logs": "^5.14.0",
|
||||
"@datadog/browser-rum": "^5.14.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.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": "^8.0.0",
|
||||
"@edx/frontend-component-footer": "^13.0.4",
|
||||
"@edx/frontend-component-header": "^5.0.2",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.0.0",
|
||||
"@edx/frontend-lib-special-exams": "^3.0.0",
|
||||
"@edx/frontend-platform": "^7.1.2",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@edx/react-unit-test-utils": "3.0.0",
|
||||
"@edx/react-unit-test-utils": "^2.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@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",
|
||||
"@openedx/frontend-plugin-framework": "^1.0.2",
|
||||
"@openedx/paragon": "^22.1.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "2.5.1",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"husky": "7.0.4",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "5.3.0",
|
||||
"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",
|
||||
@@ -70,34 +64,33 @@
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.8",
|
||||
"sass": "^1.79.3",
|
||||
"sass-loader": "^16.0.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"truncate-html": "1.1.1"
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@pact-foundation/pact": "^13.0.0",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@openedx/frontend-build": "13.0.30",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios-mock-adapter": "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",
|
||||
"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",
|
||||
"jest-when": "^3.6.0",
|
||||
"rosie": "2.1.1"
|
||||
},
|
||||
"bundlewatch": {
|
||||
"files": [
|
||||
{
|
||||
"path": "dist/*.js",
|
||||
"maxSize": "1300kB"
|
||||
}
|
||||
],
|
||||
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
diff --git a/node_modules/@openedx/frontend-build/config/webpack.prod.config.js b/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
|
||||
index 2879dd9..9efd0fc 100644
|
||||
--- a/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
|
||||
+++ b/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
|
||||
@@ -12,6 +12,7 @@ const NewRelicSourceMapPlugin = require('@edx/new-relic-source-map-webpack-plugi
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const path = require('path');
|
||||
+const fs = require('fs');
|
||||
const PostCssAutoprefixerPlugin = require('autoprefixer');
|
||||
const PostCssRTLCSS = require('postcss-rtlcss');
|
||||
const PostCssCustomMediaCSS = require('postcss-custom-media');
|
||||
@@ -23,6 +24,23 @@ const HtmlWebpackNewRelicPlugin = require('../lib/plugins/html-webpack-new-relic
|
||||
const commonConfig = require('./webpack.common.config');
|
||||
const presets = require('../lib/presets');
|
||||
|
||||
+/**
|
||||
+ * This condition confirms whether the configuration for the MFE has switched to a JS-based configuration
|
||||
+ * as previously implemented in frontend-build and frontend-platform. If the environment variable JS_CONFIG_FILEPATH
|
||||
+ * exists, then an env.config.js(x) file will be copied from the location referenced by the environment variable to the
|
||||
+ * root directory. Its env variables can be accessed with getConfig().
|
||||
+ *
|
||||
+ * https://github.com/openedx/frontend-build/blob/master/docs/0002-js-environment-config.md
|
||||
+ * https://github.com/openedx/frontend-platform/blob/master/docs/decisions/0007-javascript-file-configuration.rst
|
||||
+ */
|
||||
+
|
||||
+const envConfigPath = process.env.JS_CONFIG_FILEPATH;
|
||||
+
|
||||
+if (envConfigPath) {
|
||||
+ const envConfigFilename = envConfigPath.slice(envConfigPath.indexOf('env.config'));
|
||||
+ fs.copyFileSync(envConfigPath, envConfigFilename);
|
||||
+}
|
||||
+
|
||||
// Add process env vars. Currently used only for setting the PUBLIC_PATH.
|
||||
dotenv.config({
|
||||
path: path.resolve(process.cwd(), '.env'),
|
||||
17
plugins/README.md
Normal file
17
plugins/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## How to develop plugin
|
||||
|
||||
You can define plugin in `env.config.jsx` see `example.env.config.jsx` as example.
|
||||
|
||||
## Current caveat
|
||||
|
||||
- The way for how I deal with override method is still wonky
|
||||
- The redux still require middleware to ignore the plugin's action from serializing
|
||||
- I am not sure how it behave with useCallback, useMemo, ...etc
|
||||
- There are still open question on how to write it properly
|
||||
|
||||
## Current work that should consider core part and extendable for the future plugin framework
|
||||
|
||||
- `usePluingsCallback` is the callback supose to be some level of equality to be using `React.useCallback`. It would try to execute the function, then any plugin that try `registerOverrideMethod`. The order of the it being run isn't the determined. There are a couple things I want to add:
|
||||
- I might consider testing it with `zustand` library to make sure it is portable and not rely on `redux`. I tried to do this with provider, but it seems to run into infinite loop of trigger changed.
|
||||
|
||||
- `registerOverrideMethod` is working like a way to register callback that behave like a middleware. It ran the default one, then pass the result of the default one to the plugin. Any plugin that register the override can update the value. Alternatively, we can override the function completely instead applying each affect. Or we can support both. But it requires a bit more thought out architecture.
|
||||
@@ -0,0 +1,15 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<UnitTranslationPlugin /> render TranslationSelection when translation is enabled and language is available 1`] = `
|
||||
<TranslationSelection
|
||||
availableLanguages={
|
||||
Array [
|
||||
"en",
|
||||
]
|
||||
}
|
||||
courseId="courseId"
|
||||
id="id"
|
||||
language="en"
|
||||
unitId="unitId"
|
||||
/>
|
||||
`;
|
||||
90
plugins/UnitTranslationPlugin/data/api.js
Normal file
90
plugins/UnitTranslationPlugin/data/api.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
export const fetchTranslationConfig = async (courseId) => {
|
||||
const url = `${
|
||||
getConfig().LMS_BASE_URL
|
||||
}/api/translatable_xblocks/config/?course_id=${encodeURIComponent(courseId)}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return {
|
||||
enabled: data.feature_enabled,
|
||||
availableLanguages: data.available_translation_languages || [
|
||||
{
|
||||
code: 'en',
|
||||
label: 'English',
|
||||
},
|
||||
{
|
||||
code: 'es',
|
||||
label: 'Spanish',
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
logError(`Translation plugin fail to fetch from ${url}`, error);
|
||||
return {
|
||||
enabled: false,
|
||||
availableLanguages: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export async function getTranslationFeedback({
|
||||
courseId,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
}) {
|
||||
const params = stringify({
|
||||
translation_language: translationLanguage,
|
||||
course_id: encodeURIComponent(courseId),
|
||||
unit_id: encodeURIComponent(unitId),
|
||||
user_id: userId,
|
||||
});
|
||||
const fetchFeedbackUrl = `${
|
||||
getConfig().AI_TRANSLATIONS_URL
|
||||
}/api/v1/whole-course-translation-feedback?${params}`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(fetchFeedbackUrl);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
logError(
|
||||
`Translation plugin fail to fetch from ${fetchFeedbackUrl}`,
|
||||
error,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTranslationFeedback({
|
||||
courseId,
|
||||
feedbackValue,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
}) {
|
||||
const createFeedbackUrl = `${
|
||||
getConfig().AI_TRANSLATIONS_URL
|
||||
}/api/v1/whole-course-translation-feedback/`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
createFeedbackUrl,
|
||||
{
|
||||
course_id: courseId,
|
||||
feedback_value: feedbackValue,
|
||||
translation_language: translationLanguage,
|
||||
unit_id: unitId,
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
logError(
|
||||
`Translation plugin fail to create feedback from ${createFeedbackUrl}`,
|
||||
error,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
125
plugins/UnitTranslationPlugin/data/api.test.js
Normal file
125
plugins/UnitTranslationPlugin/data/api.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
import {
|
||||
fetchTranslationConfig,
|
||||
getTranslationFeedback,
|
||||
createTranslationFeedback,
|
||||
} from './api';
|
||||
|
||||
const mockGetMethod = jest.fn();
|
||||
const mockPostMethod = jest.fn();
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: () => ({
|
||||
get: mockGetMethod,
|
||||
post: mockPostMethod,
|
||||
}),
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('UnitTranslation api', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('fetchTranslationConfig', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const expectedResponse = {
|
||||
feature_enabled: true,
|
||||
available_translation_languages: [
|
||||
{
|
||||
code: 'en',
|
||||
label: 'English',
|
||||
},
|
||||
{
|
||||
code: 'es',
|
||||
label: 'Spanish',
|
||||
},
|
||||
],
|
||||
};
|
||||
it('should fetch translation config', async () => {
|
||||
const expectedUrl = `http://localhost:18000/api/translatable_xblocks/config/?course_id=${encodeURIComponent(
|
||||
courseId,
|
||||
)}`;
|
||||
mockGetMethod.mockResolvedValueOnce({ data: expectedResponse });
|
||||
const result = await fetchTranslationConfig(courseId);
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
availableLanguages: expectedResponse.available_translation_languages,
|
||||
});
|
||||
expect(mockGetMethod).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
|
||||
it('should return disabled and unavailable languages on error', async () => {
|
||||
mockGetMethod.mockRejectedValueOnce(new Error('error'));
|
||||
const result = await fetchTranslationConfig(courseId);
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
availableLanguages: [],
|
||||
});
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTranslationFeedback', () => {
|
||||
const props = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
translationLanguage: 'es',
|
||||
unitId: 'unit-v1:edX+DemoX+Demo_Course+type@video+block@video',
|
||||
userId: 'test_user',
|
||||
};
|
||||
const expectedResponse = {
|
||||
feedback: 'good',
|
||||
};
|
||||
it('should fetch translation feedback', async () => {
|
||||
const params = stringify({
|
||||
translation_language: props.translationLanguage,
|
||||
course_id: encodeURIComponent(props.courseId),
|
||||
unit_id: encodeURIComponent(props.unitId),
|
||||
user_id: props.userId,
|
||||
});
|
||||
const expectedUrl = `http://localhost:18760/api/v1/whole-course-translation-feedback?${params}`;
|
||||
mockGetMethod.mockResolvedValueOnce({ data: expectedResponse });
|
||||
const result = await getTranslationFeedback(props);
|
||||
expect(result).toEqual(camelCaseObject(expectedResponse));
|
||||
expect(mockGetMethod).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
|
||||
it('should return empty object on error', async () => {
|
||||
mockGetMethod.mockRejectedValueOnce(new Error('error'));
|
||||
const result = await getTranslationFeedback(props);
|
||||
expect(result).toEqual({});
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTranslationFeedback', () => {
|
||||
const props = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
feedbackValue: 'good',
|
||||
translationLanguage: 'es',
|
||||
unitId: 'unit-v1:edX+DemoX+Demo_Course+type@video+block@video',
|
||||
userId: 'test_user',
|
||||
};
|
||||
it('should create translation feedback', async () => {
|
||||
const expectedUrl = 'http://localhost:18760/api/v1/whole-course-translation-feedback/';
|
||||
mockPostMethod.mockResolvedValueOnce({});
|
||||
await createTranslationFeedback(props);
|
||||
expect(mockPostMethod).toHaveBeenCalledWith(expectedUrl, {
|
||||
course_id: props.courseId,
|
||||
feedback_value: props.feedbackValue,
|
||||
translation_language: props.translationLanguage,
|
||||
unit_id: props.unitId,
|
||||
user_id: props.userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should log error on failure', async () => {
|
||||
mockPostMethod.mockRejectedValueOnce(new Error('error'));
|
||||
await createTranslationFeedback(props);
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<FeedbackWidget /> render feedback widget 1`] = `
|
||||
<div
|
||||
className="d-none"
|
||||
>
|
||||
<div
|
||||
className="sequence w-100"
|
||||
>
|
||||
<div
|
||||
className="ml-4 mr-2"
|
||||
>
|
||||
<ActionRow>
|
||||
Rate this page translation
|
||||
<Spacer />
|
||||
<div>
|
||||
<IconButton
|
||||
alt="positive-feedback"
|
||||
className="m-1"
|
||||
iconAs="Icon"
|
||||
id="positive-feedback-button"
|
||||
onClick={[MockFunction onThumbsUpClick]}
|
||||
src="ThumbUpOutline"
|
||||
variant="secondary"
|
||||
/>
|
||||
<IconButton
|
||||
alt="negative-feedback"
|
||||
className="mr-2"
|
||||
iconAs="Icon"
|
||||
id="negative-feedback-button"
|
||||
onClick={[MockFunction onThumbsDownClick]}
|
||||
src="ThumbDownOffAlt"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="mb-1 text-light action-row-divider"
|
||||
>
|
||||
|
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
alt="close-feedback"
|
||||
className="ml-1 mr-2 float-right"
|
||||
iconAs="Icon"
|
||||
id="close-feedback-button"
|
||||
onClick={[MockFunction closeFeedbackWidget]}
|
||||
src="Close"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
</ActionRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<FeedbackWidget /> render gratitude text 1`] = `
|
||||
<div
|
||||
className="d-none"
|
||||
>
|
||||
<div
|
||||
className="sequence w-100"
|
||||
>
|
||||
<div
|
||||
className="ml-4 mr-4"
|
||||
>
|
||||
<ActionRow
|
||||
className="m-2 justify-content-center"
|
||||
>
|
||||
Thank you! Your feedback matters.
|
||||
</ActionRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<FeedbackWidget /> renders hidden by default 1`] = `
|
||||
<div
|
||||
className="d-none"
|
||||
>
|
||||
<div
|
||||
className="sequence w-100"
|
||||
>
|
||||
<div
|
||||
className="ml-4 mr-2"
|
||||
>
|
||||
<ActionRow>
|
||||
Rate this page translation
|
||||
<Spacer />
|
||||
<div>
|
||||
<IconButton
|
||||
alt="positive-feedback"
|
||||
className="m-1"
|
||||
iconAs="Icon"
|
||||
id="positive-feedback-button"
|
||||
onClick={[MockFunction onThumbsUpClick]}
|
||||
src="ThumbUpOutline"
|
||||
variant="secondary"
|
||||
/>
|
||||
<IconButton
|
||||
alt="negative-feedback"
|
||||
className="mr-2"
|
||||
iconAs="Icon"
|
||||
id="negative-feedback-button"
|
||||
onClick={[MockFunction onThumbsDownClick]}
|
||||
src="ThumbDownOffAlt"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="mb-1 text-light action-row-divider"
|
||||
>
|
||||
|
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
alt="close-feedback"
|
||||
className="ml-1 mr-2 float-right"
|
||||
iconAs="Icon"
|
||||
id="close-feedback-button"
|
||||
onClick={[MockFunction closeFeedbackWidget]}
|
||||
src="Close"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
</ActionRow>
|
||||
</div>
|
||||
<div
|
||||
className="ml-4 mr-4"
|
||||
>
|
||||
<ActionRow
|
||||
className="m-2 justify-content-center"
|
||||
>
|
||||
Thank you! Your feedback matters.
|
||||
</ActionRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<FeedbackWidget /> renders show when elemReady is true 1`] = `
|
||||
<div
|
||||
className="sequence-container d-inline-flex flex-row w-100"
|
||||
>
|
||||
<div
|
||||
className="sequence w-100"
|
||||
>
|
||||
<div
|
||||
className="ml-4 mr-2"
|
||||
>
|
||||
<ActionRow>
|
||||
Rate this page translation
|
||||
<Spacer />
|
||||
<div>
|
||||
<IconButton
|
||||
alt="positive-feedback"
|
||||
className="m-1"
|
||||
iconAs="Icon"
|
||||
id="positive-feedback-button"
|
||||
onClick={[MockFunction onThumbsUpClick]}
|
||||
src="ThumbUpOutline"
|
||||
variant="secondary"
|
||||
/>
|
||||
<IconButton
|
||||
alt="negative-feedback"
|
||||
className="mr-2"
|
||||
iconAs="Icon"
|
||||
id="negative-feedback-button"
|
||||
onClick={[MockFunction onThumbsDownClick]}
|
||||
src="ThumbDownOffAlt"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="mb-1 text-light action-row-divider"
|
||||
>
|
||||
|
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
alt="close-feedback"
|
||||
className="ml-1 mr-2 float-right"
|
||||
iconAs="Icon"
|
||||
id="close-feedback-button"
|
||||
onClick={[MockFunction closeFeedbackWidget]}
|
||||
src="Close"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
</ActionRow>
|
||||
</div>
|
||||
<div
|
||||
className="ml-4 mr-4"
|
||||
>
|
||||
<ActionRow
|
||||
className="m-2 justify-content-center"
|
||||
>
|
||||
Thank you! Your feedback matters.
|
||||
</ActionRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
116
plugins/UnitTranslationPlugin/feedback-widget/index.jsx
Normal file
116
plugins/UnitTranslationPlugin/feedback-widget/index.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, {
|
||||
useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ActionRow, IconButton, Icon } from '@openedx/paragon';
|
||||
import { Close, ThumbUpOutline, ThumbDownOffAlt } from '@openedx/paragon/icons';
|
||||
|
||||
import './index.scss';
|
||||
import messages from './messages';
|
||||
import useFeedbackWidget from './useFeedbackWidget';
|
||||
|
||||
const FeedbackWidget = ({
|
||||
courseId,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const ref = useRef(null);
|
||||
const [elemReady, setElemReady] = useState(false);
|
||||
const {
|
||||
closeFeedbackWidget,
|
||||
showFeedbackWidget,
|
||||
showGratitudeText,
|
||||
onThumbsUpClick,
|
||||
onThumbsDownClick,
|
||||
} = useFeedbackWidget({
|
||||
courseId,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const domNode = document.getElementById('whole-course-translation-feedback-widget');
|
||||
domNode.appendChild(ref.current);
|
||||
setElemReady(true);
|
||||
}
|
||||
}, [ref.current]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={(elemReady) ? 'sequence-container d-inline-flex flex-row w-100' : 'd-none'}>
|
||||
{(showFeedbackWidget || showGratitudeText) ? (
|
||||
<div className="sequence w-100">
|
||||
{
|
||||
showFeedbackWidget && (
|
||||
<div className="ml-4 mr-2">
|
||||
<ActionRow>
|
||||
{formatMessage(messages.rateTranslationText)}
|
||||
<ActionRow.Spacer />
|
||||
<div>
|
||||
<IconButton
|
||||
src={ThumbUpOutline}
|
||||
iconAs={Icon}
|
||||
alt="positive-feedback"
|
||||
onClick={onThumbsUpClick}
|
||||
variant="secondary"
|
||||
className="m-1"
|
||||
id="positive-feedback-button"
|
||||
/>
|
||||
<IconButton
|
||||
src={ThumbDownOffAlt}
|
||||
iconAs={Icon}
|
||||
alt="negative-feedback"
|
||||
onClick={onThumbsDownClick}
|
||||
variant="secondary"
|
||||
className="mr-2"
|
||||
id="negative-feedback-button"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 text-light action-row-divider">
|
||||
|
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
alt="close-feedback"
|
||||
onClick={closeFeedbackWidget}
|
||||
variant="secondary"
|
||||
className="ml-1 mr-2 float-right"
|
||||
id="close-feedback-button"
|
||||
/>
|
||||
</div>
|
||||
</ActionRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showGratitudeText && (
|
||||
<div className="ml-4 mr-4">
|
||||
<ActionRow className="m-2 justify-content-center">
|
||||
{formatMessage(messages.gratitudeText)}
|
||||
</ActionRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FeedbackWidget.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
translationLanguage: PropTypes.string.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
FeedbackWidget.defaultProps = {};
|
||||
|
||||
export default FeedbackWidget;
|
||||
4
plugins/UnitTranslationPlugin/feedback-widget/index.scss
Normal file
4
plugins/UnitTranslationPlugin/feedback-widget/index.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.action-row-divider {
|
||||
font-size: 31px;
|
||||
font-weight: 100;
|
||||
}
|
||||
107
plugins/UnitTranslationPlugin/feedback-widget/index.test.jsx
Normal file
107
plugins/UnitTranslationPlugin/feedback-widget/index.test.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import FeedbackWidget from './index';
|
||||
import useFeedbackWidget from './useFeedbackWidget';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useState: jest.fn((value) => [value, jest.fn()]),
|
||||
}));
|
||||
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({
|
||||
ActionRow: {
|
||||
Spacer: 'Spacer',
|
||||
},
|
||||
IconButton: 'IconButton',
|
||||
Icon: 'Icon',
|
||||
}));
|
||||
jest.mock('@openedx/paragon/icons', () => ({
|
||||
Close: 'Close',
|
||||
ThumbUpOutline: 'ThumbUpOutline',
|
||||
ThumbDownOffAlt: 'ThumbDownOffAlt',
|
||||
}));
|
||||
jest.mock('./useFeedbackWidget');
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
|
||||
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
|
||||
return {
|
||||
...i18n,
|
||||
useIntl: jest.fn(() => ({
|
||||
formatMessage,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<FeedbackWidget />', () => {
|
||||
const props = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
translationLanguage: 'es',
|
||||
unitId:
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@37b72b3915204b70acb00c55b604b563',
|
||||
userId: '123',
|
||||
};
|
||||
|
||||
const mockUseFeedbackWidget = ({ showFeedbackWidget, showGratitudeText }) => {
|
||||
useFeedbackWidget.mockReturnValueOnce({
|
||||
closeFeedbackWidget: jest.fn().mockName('closeFeedbackWidget'),
|
||||
sendFeedback: jest.fn().mockName('sendFeedback'),
|
||||
onThumbsUpClick: jest.fn().mockName('onThumbsUpClick'),
|
||||
onThumbsDownClick: jest.fn().mockName('onThumbsDownClick'),
|
||||
showFeedbackWidget,
|
||||
showGratitudeText,
|
||||
});
|
||||
};
|
||||
|
||||
it('renders hidden by default', () => {
|
||||
mockUseFeedbackWidget({
|
||||
showFeedbackWidget: true,
|
||||
showGratitudeText: true,
|
||||
});
|
||||
const wrapper = shallow(<FeedbackWidget {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
expect(wrapper.instance.findByType('div')[0].props.className).toContain(
|
||||
'd-none',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders show when elemReady is true', () => {
|
||||
mockUseFeedbackWidget({
|
||||
showFeedbackWidget: true,
|
||||
showGratitudeText: true,
|
||||
});
|
||||
useState.mockReturnValueOnce([true, jest.fn()]);
|
||||
const wrapper = shallow(<FeedbackWidget {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
expect(wrapper.instance.findByType('div')[0].props.className).not.toContain(
|
||||
'd-none',
|
||||
);
|
||||
});
|
||||
|
||||
it('render empty when showFeedbackWidget and showGratitudeText are false', () => {
|
||||
mockUseFeedbackWidget({
|
||||
showFeedbackWidget: false,
|
||||
showGratitudeText: false,
|
||||
});
|
||||
useState.mockReturnValueOnce([true, jest.fn()]);
|
||||
const wrapper = shallow(<FeedbackWidget {...props} />);
|
||||
expect(wrapper.instance.findByType('div')[0].children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('render feedback widget', () => {
|
||||
mockUseFeedbackWidget({
|
||||
showFeedbackWidget: true,
|
||||
showGratitudeText: false,
|
||||
});
|
||||
const wrapper = shallow(<FeedbackWidget {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('render gratitude text', () => {
|
||||
mockUseFeedbackWidget({
|
||||
showFeedbackWidget: false,
|
||||
showGratitudeText: true,
|
||||
});
|
||||
const wrapper = shallow(<FeedbackWidget {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
16
plugins/UnitTranslationPlugin/feedback-widget/messages.js
Normal file
16
plugins/UnitTranslationPlugin/feedback-widget/messages.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
rateTranslationText: {
|
||||
id: 'feedbackWidget.rateTranslationText',
|
||||
defaultMessage: 'Rate this page translation',
|
||||
description: 'Title for the feedback widget action row.',
|
||||
},
|
||||
gratitudeText: {
|
||||
id: 'feedbackWidget.gratitudeText',
|
||||
defaultMessage: 'Thank you! Your feedback matters.',
|
||||
description: 'Title for secondary action row.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { createTranslationFeedback, getTranslationFeedback } from '../data/api';
|
||||
|
||||
const useFeedbackWidget = ({
|
||||
courseId,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
}) => {
|
||||
const [showFeedbackWidget, setShowFeedbackWidget] = useState(false);
|
||||
const [showGratitudeText, setShowGratitudeText] = useState(false);
|
||||
|
||||
const closeFeedbackWidget = useCallback(() => {
|
||||
setShowFeedbackWidget(false);
|
||||
}, [setShowFeedbackWidget]);
|
||||
|
||||
const openFeedbackWidget = useCallback(() => {
|
||||
setShowFeedbackWidget(true);
|
||||
}, [setShowFeedbackWidget]);
|
||||
|
||||
useEffect(async () => {
|
||||
const translationFeedback = await getTranslationFeedback({
|
||||
courseId,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
});
|
||||
setShowFeedbackWidget(!translationFeedback);
|
||||
}, [
|
||||
courseId,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const openGratitudeText = useCallback(() => {
|
||||
setShowGratitudeText(true);
|
||||
setTimeout(() => {
|
||||
setShowGratitudeText(false);
|
||||
}, 3000);
|
||||
}, [setShowGratitudeText]);
|
||||
|
||||
const sendFeedback = useCallback(async (feedbackValue) => {
|
||||
await createTranslationFeedback({
|
||||
courseId,
|
||||
feedbackValue,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
});
|
||||
closeFeedbackWidget();
|
||||
openGratitudeText();
|
||||
}, [
|
||||
courseId,
|
||||
translationLanguage,
|
||||
unitId,
|
||||
userId,
|
||||
closeFeedbackWidget,
|
||||
openGratitudeText,
|
||||
]);
|
||||
|
||||
const onThumbsUpClick = useCallback(() => {
|
||||
sendFeedback(true);
|
||||
}, [sendFeedback]);
|
||||
const onThumbsDownClick = useCallback(() => {
|
||||
sendFeedback(false);
|
||||
}, [sendFeedback]);
|
||||
|
||||
return {
|
||||
closeFeedbackWidget,
|
||||
openFeedbackWidget,
|
||||
openGratitudeText,
|
||||
sendFeedback,
|
||||
showFeedbackWidget,
|
||||
showGratitudeText,
|
||||
onThumbsUpClick,
|
||||
onThumbsDownClick,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFeedbackWidget;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
|
||||
import useFeedbackWidget from './useFeedbackWidget';
|
||||
import { createTranslationFeedback, getTranslationFeedback } from '../data/api';
|
||||
|
||||
jest.mock('../data/api', () => ({
|
||||
createTranslationFeedback: jest.fn(),
|
||||
getTranslationFeedback: jest.fn(),
|
||||
}));
|
||||
|
||||
const initialProps = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
translationLanguage: 'es',
|
||||
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
userId: 3,
|
||||
};
|
||||
|
||||
const newProps = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
translationLanguage: 'fr',
|
||||
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
userId: 3,
|
||||
};
|
||||
|
||||
describe('useFeedbackWidget', () => {
|
||||
beforeEach(async () => {
|
||||
getTranslationFeedback.mockReturnValue('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('closeFeedbackWidget behavior', () => {
|
||||
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
|
||||
waitFor(() => expect(result.current.showFeedbackWidget.toBe(true)));
|
||||
act(() => {
|
||||
result.current.closeFeedbackWidget();
|
||||
});
|
||||
expect(result.current.showFeedbackWidget).toBe(false);
|
||||
});
|
||||
|
||||
test('openFeedbackWidget behavior', () => {
|
||||
const { result } = renderHook(() => useFeedbackWidget(initialProps));
|
||||
act(() => {
|
||||
result.current.closeFeedbackWidget();
|
||||
});
|
||||
expect(result.current.showFeedbackWidget).toBe(false);
|
||||
act(() => {
|
||||
result.current.openFeedbackWidget();
|
||||
});
|
||||
expect(result.current.showFeedbackWidget).toBe(true);
|
||||
});
|
||||
|
||||
test('openGratitudeText behavior', async () => {
|
||||
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
|
||||
|
||||
expect(result.current.showGratitudeText).toBe(false);
|
||||
act(() => {
|
||||
result.current.openGratitudeText();
|
||||
});
|
||||
expect(result.current.showGratitudeText).toBe(true);
|
||||
// Wait for 3 seconds to hide the gratitude text
|
||||
waitFor(() => {
|
||||
expect(result.current.showGratitudeText).toBe(false);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('sendFeedback behavior', () => {
|
||||
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
|
||||
const feedbackValue = true;
|
||||
|
||||
waitFor(() => expect(result.current.showFeedbackWidget.toBe(true)));
|
||||
|
||||
expect(result.current.showGratitudeText).toBe(false);
|
||||
act(() => {
|
||||
result.current.sendFeedback(feedbackValue);
|
||||
});
|
||||
|
||||
waitFor(() => {
|
||||
expect(result.current.showFeedbackWidget).toBe(false);
|
||||
expect(result.current.showGratitudeText).toBe(true);
|
||||
});
|
||||
|
||||
expect(createTranslationFeedback).toHaveBeenCalledWith({
|
||||
courseId: initialProps.courseId,
|
||||
feedbackValue,
|
||||
translationLanguage: initialProps.translationLanguage,
|
||||
unitId: initialProps.unitId,
|
||||
userId: initialProps.userId,
|
||||
});
|
||||
|
||||
// Wait for 3 seconds to hide the gratitude text
|
||||
waitFor(() => {
|
||||
expect(result.current.showGratitudeText).toBe(false);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('onThumbsUpClick behavior', () => {
|
||||
const { result } = renderHook(() => useFeedbackWidget(initialProps));
|
||||
|
||||
act(() => {
|
||||
result.current.onThumbsUpClick();
|
||||
});
|
||||
|
||||
expect(createTranslationFeedback).toHaveBeenCalledWith({
|
||||
courseId: initialProps.courseId,
|
||||
feedbackValue: true,
|
||||
translationLanguage: initialProps.translationLanguage,
|
||||
unitId: initialProps.unitId,
|
||||
userId: initialProps.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test('onThumbsDownClick behavior', () => {
|
||||
const { result } = renderHook(() => useFeedbackWidget(initialProps));
|
||||
|
||||
act(() => {
|
||||
result.current.onThumbsDownClick();
|
||||
});
|
||||
|
||||
expect(createTranslationFeedback).toHaveBeenCalledWith({
|
||||
courseId: initialProps.courseId,
|
||||
feedbackValue: false,
|
||||
translationLanguage: initialProps.translationLanguage,
|
||||
unitId: initialProps.unitId,
|
||||
userId: initialProps.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test('fetch feedback on initialization', () => {
|
||||
const { waitFor } = renderHook(() => useFeedbackWidget(initialProps));
|
||||
waitFor(() => {
|
||||
expect(getTranslationFeedback).toHaveBeenCalledWith({
|
||||
courseId: initialProps.courseId,
|
||||
translationLanguage: initialProps.translationLanguage,
|
||||
unitId: initialProps.unitId,
|
||||
userId: initialProps.userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('fetch feedback on props update', () => {
|
||||
const { rerender, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
|
||||
waitFor(() => {
|
||||
expect(getTranslationFeedback).toHaveBeenCalledWith({
|
||||
courseId: initialProps.courseId,
|
||||
translationLanguage: initialProps.translationLanguage,
|
||||
unitId: initialProps.unitId,
|
||||
userId: initialProps.userId,
|
||||
});
|
||||
});
|
||||
rerender(newProps);
|
||||
waitFor(() => {
|
||||
expect(getTranslationFeedback).toHaveBeenCalledWith({
|
||||
courseId: newProps.courseId,
|
||||
translationLanguage: newProps.translationLanguage,
|
||||
unitId: newProps.unitId,
|
||||
userId: newProps.userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
43
plugins/UnitTranslationPlugin/index.jsx
Normal file
43
plugins/UnitTranslationPlugin/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
|
||||
import TranslationSelection from './translation-selection';
|
||||
import { fetchTranslationConfig } from './data/api';
|
||||
|
||||
const UnitTranslationPlugin = ({ id, courseId, unitId }) => {
|
||||
const { language } = useModel('coursewareMeta', courseId);
|
||||
const [translationConfig, setTranslationConfig] = useState({
|
||||
enabled: false,
|
||||
availableLanguages: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchTranslationConfig(courseId).then(setTranslationConfig);
|
||||
}, []);
|
||||
|
||||
const { enabled, availableLanguages } = translationConfig;
|
||||
|
||||
if (!enabled || !language || !availableLanguages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TranslationSelection
|
||||
id={id}
|
||||
courseId={courseId}
|
||||
language={language}
|
||||
availableLanguages={availableLanguages}
|
||||
unitId={unitId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
UnitTranslationPlugin.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default UnitTranslationPlugin;
|
||||
62
plugins/UnitTranslationPlugin/index.test.jsx
Normal file
62
plugins/UnitTranslationPlugin/index.test.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
import { useState } from 'react';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
|
||||
import UnitTranslationPlugin from './index';
|
||||
|
||||
jest.mock('@src/generic/model-store');
|
||||
jest.mock('./data/api', () => ({
|
||||
fetchTranslationConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('./translation-selection', () => 'TranslationSelection');
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useState: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('<UnitTranslationPlugin />', () => {
|
||||
const props = {
|
||||
id: 'id',
|
||||
courseId: 'courseId',
|
||||
unitId: 'unitId',
|
||||
};
|
||||
const mockInitialState = ({ enabled = true, availableLanguages = ['en'] }) => {
|
||||
useState.mockReturnValue([{ enabled, availableLanguages }, jest.fn()]);
|
||||
};
|
||||
it('render empty when translation is not enabled', () => {
|
||||
useModel.mockReturnValue({ language: 'en' });
|
||||
mockInitialState({ enabled: false });
|
||||
|
||||
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
|
||||
|
||||
expect(wrapper.isEmptyRender()).toBe(true);
|
||||
});
|
||||
it('render empty when available languages is empty', () => {
|
||||
useModel.mockReturnValue({ language: 'fr' });
|
||||
mockInitialState({
|
||||
availableLanguages: [],
|
||||
});
|
||||
|
||||
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
|
||||
|
||||
expect(wrapper.isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('render empty when course language has not been set', () => {
|
||||
useModel.mockReturnValue({ language: undefined });
|
||||
mockInitialState({});
|
||||
|
||||
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
|
||||
|
||||
expect(wrapper.isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('render TranslationSelection when translation is enabled and language is available', () => {
|
||||
useModel.mockReturnValue({ language: 'en' });
|
||||
mockInitialState({});
|
||||
|
||||
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
|
||||
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
StandardModal,
|
||||
ActionRow,
|
||||
Button,
|
||||
Icon,
|
||||
ListBox,
|
||||
ListBoxOption,
|
||||
} from '@openedx/paragon';
|
||||
import { Check } from '@openedx/paragon/icons';
|
||||
|
||||
import useTranslationModal from './useTranslationModal';
|
||||
import messages from './messages';
|
||||
|
||||
import './TranslationModal.scss';
|
||||
|
||||
const TranslationModal = ({
|
||||
isOpen,
|
||||
close,
|
||||
selectedLanguage,
|
||||
setSelectedLanguage,
|
||||
availableLanguages,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { selectedIndex, setSelectedIndex, onSubmit } = useTranslationModal({
|
||||
selectedLanguage,
|
||||
setSelectedLanguage,
|
||||
close,
|
||||
availableLanguages,
|
||||
});
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
title={formatMessage(messages.languageSelectionModalTitle)}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<ActionRow.Spacer />
|
||||
<Button variant="tertiary" onClick={close}>
|
||||
{formatMessage(messages.cancelButtonText)}
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>
|
||||
{formatMessage(messages.submitButtonText)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<ListBox className="listbox-container">
|
||||
{availableLanguages.map(({ code, label }, index) => (
|
||||
<ListBoxOption
|
||||
className="d-flex justify-content-between"
|
||||
key={code}
|
||||
selectedOptionIndex={selectedIndex}
|
||||
onSelect={() => setSelectedIndex(index)}
|
||||
>
|
||||
{label}
|
||||
{selectedIndex === index && <Icon src={Check} />}
|
||||
</ListBoxOption>
|
||||
))}
|
||||
</ListBox>
|
||||
</StandardModal>
|
||||
);
|
||||
};
|
||||
|
||||
TranslationModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
close: PropTypes.func.isRequired,
|
||||
selectedLanguage: PropTypes.string.isRequired,
|
||||
setSelectedLanguage: PropTypes.func.isRequired,
|
||||
availableLanguages: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
code: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
export default TranslationModal;
|
||||
@@ -0,0 +1,7 @@
|
||||
.listbox-container {
|
||||
max-height: 400px;
|
||||
|
||||
:last-child {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import TranslationModal from './TranslationModal';
|
||||
|
||||
jest.mock('./useTranslationModal', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
selectedIndex: 0,
|
||||
setSelectedIndex: jest.fn(),
|
||||
onSubmit: jest.fn().mockName('onSubmit'),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({
|
||||
StandardModal: 'StandardModal',
|
||||
ActionRow: {
|
||||
Spacer: 'Spacer',
|
||||
},
|
||||
Button: 'Button',
|
||||
Icon: 'Icon',
|
||||
ListBox: 'ListBox',
|
||||
ListBoxOption: 'ListBoxOption',
|
||||
}));
|
||||
jest.mock('@openedx/paragon/icons', () => ({
|
||||
Check: jest.fn().mockName('icons.Check'),
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
|
||||
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
|
||||
return {
|
||||
...i18n,
|
||||
useIntl: jest.fn(() => ({
|
||||
formatMessage,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('TranslationModal', () => {
|
||||
const props = {
|
||||
isOpen: true,
|
||||
close: jest.fn().mockName('close'),
|
||||
selectedLanguage: 'en',
|
||||
setSelectedLanguage: jest.fn().mockName('setSelectedLanguage'),
|
||||
availableLanguages: [
|
||||
{
|
||||
code: 'en',
|
||||
label: 'English',
|
||||
},
|
||||
{
|
||||
code: 'es',
|
||||
label: 'Spanish',
|
||||
},
|
||||
],
|
||||
};
|
||||
it('renders correctly', () => {
|
||||
const wrapper = shallow(<TranslationModal {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
expect(wrapper.instance.findByType('ListBoxOption')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TranslationModal renders correctly 1`] = `
|
||||
<StandardModal
|
||||
footerNode={
|
||||
<ActionRow>
|
||||
<Spacer />
|
||||
<Button
|
||||
onClick={[MockFunction close]}
|
||||
variant="tertiary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={[MockFunction onSubmit]}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</ActionRow>
|
||||
}
|
||||
isOpen={true}
|
||||
onClose={[MockFunction close]}
|
||||
title="Translate this course"
|
||||
>
|
||||
<ListBox
|
||||
className="listbox-container"
|
||||
>
|
||||
<ListBoxOption
|
||||
className="d-flex justify-content-between"
|
||||
key="en"
|
||||
onSelect={[Function]}
|
||||
selectedOptionIndex={0}
|
||||
>
|
||||
English
|
||||
<Icon
|
||||
src={[MockFunction icons.Check]}
|
||||
/>
|
||||
</ListBoxOption>
|
||||
<ListBoxOption
|
||||
className="d-flex justify-content-between"
|
||||
key="es"
|
||||
onSelect={[Function]}
|
||||
selectedOptionIndex={0}
|
||||
>
|
||||
Spanish
|
||||
</ListBoxOption>
|
||||
</ListBox>
|
||||
</StandardModal>
|
||||
`;
|
||||
@@ -0,0 +1,50 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<TranslationSelection /> renders 1`] = `
|
||||
<Fragment>
|
||||
<ProductTour
|
||||
tours={
|
||||
Array [
|
||||
Object {
|
||||
"abitrarily": "defined",
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
alt="change-language"
|
||||
className="mr-2 mb-2 float-right"
|
||||
iconAs="Icon"
|
||||
id="translation-selection-button"
|
||||
onClick={[MockFunction open]}
|
||||
src="Language"
|
||||
variant="primary"
|
||||
/>
|
||||
<TranslationModal
|
||||
availableLanguages={
|
||||
Array [
|
||||
Object {
|
||||
"code": "en",
|
||||
"label": "English",
|
||||
},
|
||||
Object {
|
||||
"code": "es",
|
||||
"label": "Spanish",
|
||||
},
|
||||
]
|
||||
}
|
||||
close={[MockFunction close]}
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
id="plugin-test-id"
|
||||
isOpen={false}
|
||||
selectedLanguage="en"
|
||||
setSelectedLanguage={[MockFunction setSelectedLanguage]}
|
||||
/>
|
||||
<FeedbackWidget
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
translationLanguage="en"
|
||||
unitId="unit-test-id"
|
||||
userId="123"
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
100
plugins/UnitTranslationPlugin/translation-selection/index.jsx
Normal file
100
plugins/UnitTranslationPlugin/translation-selection/index.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { IconButton, Icon, ProductTour } from '@openedx/paragon';
|
||||
import { Language } from '@openedx/paragon/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { stringifyUrl } from 'query-string';
|
||||
|
||||
import { registerOverrideMethod } from '@src/generic/plugin-store';
|
||||
|
||||
import TranslationModal from './TranslationModal';
|
||||
import useTranslationTour from './useTranslationTour';
|
||||
import useSelectLanguage from './useSelectLanguage';
|
||||
import FeedbackWidget from '../feedback-widget';
|
||||
|
||||
const TranslationSelection = ({
|
||||
id, courseId, language, availableLanguages, unitId,
|
||||
}) => {
|
||||
const {
|
||||
authenticatedUser: { userId },
|
||||
} = useContext(AppContext);
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
translationTour, isOpen, open, close,
|
||||
} = useTranslationTour();
|
||||
|
||||
const { selectedLanguage, setSelectedLanguage } = useSelectLanguage({
|
||||
courseId,
|
||||
language,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
registerOverrideMethod({
|
||||
pluginName: id,
|
||||
methodName: 'getIFrameUrl',
|
||||
method: (iframeUrl) => {
|
||||
const finalUrl = stringifyUrl({
|
||||
url: iframeUrl,
|
||||
query: {
|
||||
...(language
|
||||
&& selectedLanguage
|
||||
&& language !== selectedLanguage && {
|
||||
src_lang: language,
|
||||
dest_lang: selectedLanguage,
|
||||
}),
|
||||
},
|
||||
});
|
||||
return finalUrl;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [language, selectedLanguage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProductTour tours={[translationTour]} />
|
||||
<IconButton
|
||||
src={Language}
|
||||
iconAs={Icon}
|
||||
alt="change-language"
|
||||
onClick={open}
|
||||
variant="primary"
|
||||
className="mr-2 mb-2 float-right"
|
||||
id="translation-selection-button"
|
||||
/>
|
||||
<TranslationModal
|
||||
isOpen={isOpen}
|
||||
close={close}
|
||||
courseId={courseId}
|
||||
selectedLanguage={selectedLanguage}
|
||||
setSelectedLanguage={setSelectedLanguage}
|
||||
availableLanguages={availableLanguages}
|
||||
id={id}
|
||||
/>
|
||||
<FeedbackWidget
|
||||
courseId={courseId}
|
||||
translationLanguage={selectedLanguage}
|
||||
unitId={unitId}
|
||||
userId={userId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
TranslationSelection.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
language: PropTypes.string.isRequired,
|
||||
availableLanguages: PropTypes.arrayOf(PropTypes.shape({
|
||||
code: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
TranslationSelection.defaultProps = {};
|
||||
|
||||
export default TranslationSelection;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
|
||||
import TranslationSelection from './index';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useContext: jest.fn().mockName('useContext').mockReturnValue({
|
||||
authenticatedUser: {
|
||||
userId: '123',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('@openedx/paragon', () => ({
|
||||
IconButton: 'IconButton',
|
||||
Icon: 'Icon',
|
||||
ProductTour: 'ProductTour',
|
||||
}));
|
||||
jest.mock('@openedx/paragon/icons', () => ({
|
||||
Language: 'Language',
|
||||
}));
|
||||
jest.mock('./useTranslationTour', () => () => ({
|
||||
translationTour: {
|
||||
abitrarily: 'defined',
|
||||
},
|
||||
isOpen: false,
|
||||
open: jest.fn().mockName('open'),
|
||||
close: jest.fn().mockName('close'),
|
||||
}));
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: jest.fn().mockName('useDispatch'),
|
||||
}));
|
||||
jest.mock('@src/generic/plugin-store', () => ({
|
||||
registerOverrideMethod: jest.fn().mockName('registerOverrideMethod'),
|
||||
}));
|
||||
jest.mock('./TranslationModal', () => 'TranslationModal');
|
||||
jest.mock('./useSelectLanguage', () => () => ({
|
||||
selectedLanguage: 'en',
|
||||
setSelectedLanguage: jest.fn().mockName('setSelectedLanguage'),
|
||||
}));
|
||||
jest.mock('../feedback-widget', () => 'FeedbackWidget');
|
||||
|
||||
describe('<TranslationSelection />', () => {
|
||||
const props = {
|
||||
id: 'plugin-test-id',
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
language: 'en',
|
||||
availableLanguages: [
|
||||
{
|
||||
code: 'en',
|
||||
label: 'English',
|
||||
},
|
||||
{
|
||||
code: 'es',
|
||||
label: 'Spanish',
|
||||
},
|
||||
],
|
||||
unitId: 'unit-test-id',
|
||||
};
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<TranslationSelection {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
translationTourModalTitle: {
|
||||
id: 'translationSelection.translationTourModalTitle',
|
||||
defaultMessage: 'This is a standard modal dialog',
|
||||
description: 'Title for the translation modal.',
|
||||
},
|
||||
translationTourModalBody: {
|
||||
id: 'translationSelection.translationTourModalBody',
|
||||
defaultMessage: 'Now you can easily translate course content.',
|
||||
description: 'Body for the translation modal.',
|
||||
},
|
||||
tryItButtonText: {
|
||||
id: 'translationSelection.tryItButtonText',
|
||||
defaultMessage: 'Try it',
|
||||
description: 'Button text for the translation modal.',
|
||||
},
|
||||
dismissButtonText: {
|
||||
id: 'translationSelection.dismissButtonText',
|
||||
defaultMessage: 'Dismiss',
|
||||
description: 'Button text for the translation modal.',
|
||||
},
|
||||
languageSelectionModalTitle: {
|
||||
id: 'translationSelection.languageSelectionModalTitle',
|
||||
defaultMessage: 'Translate this course',
|
||||
description: 'Title for the translation modal.',
|
||||
},
|
||||
cancelButtonText: {
|
||||
id: 'translationSelection.cancelButtonText',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Button text for the translation modal.',
|
||||
},
|
||||
submitButtonText: {
|
||||
id: 'translationSelection.submitButtonText',
|
||||
defaultMessage: 'Submit',
|
||||
description: 'Button text for the translation modal.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback } from 'react';
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
|
||||
import {
|
||||
getLocalStorage,
|
||||
setLocalStorage,
|
||||
} from '@src/data/localStorage';
|
||||
|
||||
export const selectedLanguageKey = 'selectedLanguages';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
selectedLanguage: 'selectedLanguage',
|
||||
});
|
||||
|
||||
const useSelectLanguage = ({ courseId, language }) => {
|
||||
const selectedLanguageItem = getLocalStorage(selectedLanguageKey) || {};
|
||||
const [selectedLanguage, updateSelectedLanguage] = useKeyedState(
|
||||
stateKeys.selectedLanguage,
|
||||
selectedLanguageItem[courseId] || language,
|
||||
);
|
||||
|
||||
const setSelectedLanguage = useCallback((newSelectedLanguage) => {
|
||||
setLocalStorage(selectedLanguageKey, {
|
||||
...selectedLanguageItem,
|
||||
[courseId]: newSelectedLanguage,
|
||||
});
|
||||
updateSelectedLanguage(newSelectedLanguage);
|
||||
});
|
||||
|
||||
return {
|
||||
selectedLanguage,
|
||||
setSelectedLanguage,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSelectLanguage;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import {
|
||||
getLocalStorage,
|
||||
setLocalStorage,
|
||||
} from '@src/data/localStorage';
|
||||
|
||||
import useSelectLanguage, {
|
||||
stateKeys,
|
||||
selectedLanguageKey,
|
||||
} from './useSelectLanguage';
|
||||
|
||||
const state = mockUseKeyedState(stateKeys);
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useCallback: jest.fn((cb, prereqs) => (...args) => [
|
||||
cb(...args),
|
||||
{ cb, prereqs },
|
||||
]),
|
||||
}));
|
||||
jest.mock('@src/data/localStorage', () => ({
|
||||
getLocalStorage: jest.fn(),
|
||||
setLocalStorage: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useSelectLanguage', () => {
|
||||
const props = {
|
||||
courseId: 'test-course-id',
|
||||
language: 'en',
|
||||
};
|
||||
const languages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Spanish' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
state.mock();
|
||||
});
|
||||
afterEach(() => {
|
||||
state.resetVals();
|
||||
});
|
||||
|
||||
languages.forEach(({ code, label }) => {
|
||||
it(`initializes selectedLanguage to the selected language (${label})`, () => {
|
||||
getLocalStorage.mockReturnValueOnce({ [props.courseId]: code });
|
||||
const { selectedLanguage } = useSelectLanguage(props);
|
||||
|
||||
state.expectInitializedWith(stateKeys.selectedLanguage, code);
|
||||
expect(selectedLanguage).toBe(code);
|
||||
});
|
||||
});
|
||||
|
||||
test('setSelectedLanguage behavior', () => {
|
||||
const { setSelectedLanguage } = useSelectLanguage(props);
|
||||
|
||||
setSelectedLanguage('es');
|
||||
state.expectSetStateCalledWith(stateKeys.selectedLanguage, 'es');
|
||||
expect(setLocalStorage).toHaveBeenCalledWith(selectedLanguageKey, {
|
||||
[props.courseId]: 'es',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback } from 'react';
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
selectedIndex: 'selectedIndex',
|
||||
});
|
||||
|
||||
const useTranslationModal = ({
|
||||
selectedLanguage, setSelectedLanguage, close, availableLanguages,
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useKeyedState(
|
||||
stateKeys.selectedIndex,
|
||||
availableLanguages.findIndex((lang) => lang.code === selectedLanguage),
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
const newSelectedLanguage = availableLanguages[selectedIndex].code;
|
||||
setSelectedLanguage(newSelectedLanguage);
|
||||
close();
|
||||
}, [selectedIndex]);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
onSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTranslationModal;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
|
||||
import useTranslationModal, { stateKeys } from './useTranslationModal';
|
||||
|
||||
const state = mockUseKeyedState(stateKeys);
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useCallback: jest.fn((cb, prereqs) => (...args) => ([
|
||||
cb(...args), { cb, prereqs },
|
||||
])),
|
||||
}));
|
||||
|
||||
describe('useTranslationModal', () => {
|
||||
const props = {
|
||||
selectedLanguage: 'en',
|
||||
setSelectedLanguage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
availableLanguages: [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Spanish' },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
state.mock();
|
||||
});
|
||||
afterEach(() => {
|
||||
state.resetVals();
|
||||
});
|
||||
|
||||
it('initializes selectedIndex to the index of the selected language', () => {
|
||||
const { selectedIndex } = useTranslationModal(props);
|
||||
|
||||
state.expectInitializedWith(stateKeys.selectedIndex, 0);
|
||||
expect(selectedIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('onSubmit updates the selected language and closes the modal', () => {
|
||||
const { onSubmit } = useTranslationModal({
|
||||
...props,
|
||||
selectedLanguage: 'es',
|
||||
});
|
||||
onSubmit();
|
||||
expect(props.setSelectedLanguage).toHaveBeenCalledWith('es');
|
||||
expect(props.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const hasSeenTranslationTourKey = 'hasSeenTranslationTour';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
showTranslationTour: 'showTranslationTour',
|
||||
});
|
||||
|
||||
const useTranslationTour = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const [isTourEnabled, setIsTourEnabled] = useKeyedState(
|
||||
stateKeys.showTranslationTour,
|
||||
global.localStorage.getItem(hasSeenTranslationTourKey) !== 'true',
|
||||
);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
|
||||
const endTour = useCallback(() => {
|
||||
global.localStorage.setItem(hasSeenTranslationTourKey, 'true');
|
||||
setIsTourEnabled(false);
|
||||
}, [isTourEnabled, setIsTourEnabled]);
|
||||
|
||||
const tryIt = useCallback(() => {
|
||||
endTour();
|
||||
open();
|
||||
}, [endTour, open]);
|
||||
|
||||
const translationTour = isTourEnabled
|
||||
? {
|
||||
tourId: 'translation',
|
||||
enabled: isTourEnabled,
|
||||
onDismiss: endTour,
|
||||
onEnd: tryIt,
|
||||
checkpoints: [
|
||||
{
|
||||
title: formatMessage(messages.translationTourModalTitle),
|
||||
body: formatMessage(messages.translationTourModalBody),
|
||||
placement: 'bottom',
|
||||
target: '#translation-selection-button',
|
||||
showDismissButton: true,
|
||||
endButtonText: formatMessage(messages.tryItButtonText),
|
||||
dismissButtonText: formatMessage(messages.dismissButtonText),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
translationTour,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTranslationTour;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import useTranslationTour, { stateKeys } from './useTranslationTour';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useCallback: jest.fn((cb, prereqs) => () => {
|
||||
cb();
|
||||
return { useCallback: { cb, prereqs } };
|
||||
}),
|
||||
}));
|
||||
jest.mock('@openedx/paragon', () => ({
|
||||
useToggle: jest.fn(),
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
|
||||
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
|
||||
// this provide consistent for the test on different platform/timezone
|
||||
const formatDate = jest.fn(date => new Date(date).toISOString()).mockName('useIntl.formatDate');
|
||||
return {
|
||||
...i18n,
|
||||
useIntl: jest.fn(() => ({
|
||||
formatMessage,
|
||||
formatDate,
|
||||
})),
|
||||
defineMessages: m => m,
|
||||
FormattedMessage: () => 'FormattedMessage',
|
||||
};
|
||||
});
|
||||
jest.mock('@src/data/localStorage', () => ({
|
||||
getLocalStorage: jest.fn(),
|
||||
setLocalStorage: jest.fn(),
|
||||
}));
|
||||
|
||||
const state = mockUseKeyedState(stateKeys);
|
||||
|
||||
describe('useTranslationSelection', () => {
|
||||
const mockLocalStroage = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
};
|
||||
|
||||
const toggleOpen = jest.fn();
|
||||
const toggleClose = jest.fn();
|
||||
|
||||
useToggle.mockReturnValue([false, toggleOpen, toggleClose]);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
state.mock();
|
||||
window.localStorage = mockLocalStroage;
|
||||
});
|
||||
afterEach(() => {
|
||||
state.resetVals();
|
||||
delete window.localStorage;
|
||||
});
|
||||
|
||||
it('do not have translation tour if user already seen it', () => {
|
||||
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
|
||||
const { translationTour } = useTranslationTour();
|
||||
|
||||
expect(translationTour.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('show translation tour if user has not seen it', () => {
|
||||
mockLocalStroage.getItem.mockReturnValueOnce('true');
|
||||
const { translationTour } = useTranslationTour();
|
||||
|
||||
expect(translationTour).toMatchObject({});
|
||||
});
|
||||
test('open and close as pass from useToggle', () => {
|
||||
const { isOpen, open, close } = useTranslationTour();
|
||||
expect(isOpen).toBe(false);
|
||||
expect(toggleOpen).toBe(open);
|
||||
expect(toggleClose).toBe(close);
|
||||
});
|
||||
test('end tour on dismiss button click', () => {
|
||||
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
|
||||
const { translationTour } = useTranslationTour();
|
||||
translationTour.onDismiss();
|
||||
expect(mockLocalStroage.setItem).toHaveBeenCalledWith(
|
||||
'hasSeenTranslationTour',
|
||||
'true',
|
||||
);
|
||||
state.expectSetStateCalledWith(stateKeys.showTranslationTour, false);
|
||||
});
|
||||
test('end tour and open modal on try it button click', () => {
|
||||
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
|
||||
const { translationTour } = useTranslationTour();
|
||||
translationTour.onEnd();
|
||||
state.expectSetStateCalledWith(stateKeys.showTranslationTour, false);
|
||||
expect(toggleOpen).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ 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',
|
||||
@@ -34,24 +33,3 @@ export const REDIRECT_MODES = {
|
||||
HOME_REDIRECT: 'home-redirect',
|
||||
SURVEY_REDIRECT: 'survey-redirect',
|
||||
};
|
||||
|
||||
export const VERIFIED_MODES = [
|
||||
'professional',
|
||||
'verified',
|
||||
'no-id-professional',
|
||||
'credit',
|
||||
'masters',
|
||||
'executive-education',
|
||||
'paid-executive-education',
|
||||
'paid-bootcamp',
|
||||
];
|
||||
|
||||
export const WIDGETS = {
|
||||
DISCUSSIONS: 'DISCUSSIONS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
};
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { ManageSearch } from '@openedx/paragon/icons';
|
||||
import { Button, Icon } from '@openedx/paragon';
|
||||
import { Search } from '@openedx/paragon/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import messages from './messages';
|
||||
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
|
||||
@@ -25,17 +25,16 @@ const CoursewareSearchToggle = ({
|
||||
if (!enabled) { return null; }
|
||||
|
||||
return (
|
||||
<div className="courseware-search-toggle">
|
||||
<div className="courseware-searc-toggle">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="p-1 mt-2 mr-2"
|
||||
className="p-1 mt-2 mr-2 rounded-lg"
|
||||
aria-label={intl.formatMessage(messages.searchOpenAction)}
|
||||
onClick={handleSearchOpenClick}
|
||||
data-testid="courseware-search-open-button"
|
||||
iconAfter={ManageSearch}
|
||||
>
|
||||
{intl.formatMessage(messages.contentSearchButton)}
|
||||
<Icon src={Search} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
|
||||
{
|
||||
"filters": [
|
||||
{
|
||||
Object {
|
||||
"filters": Array [
|
||||
Object {
|
||||
"count": 7,
|
||||
"key": "capa",
|
||||
"label": "CAPA",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"count": 2,
|
||||
"key": "sequence",
|
||||
"label": "Sequence",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"count": 9,
|
||||
"key": "text",
|
||||
"label": "Text",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"count": 1,
|
||||
"key": "unknown",
|
||||
"label": "Unknown",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"count": 2,
|
||||
"key": "video",
|
||||
"label": "Video",
|
||||
@@ -31,11 +31,11 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
],
|
||||
"maxScore": 3.4545178,
|
||||
"ms": 5,
|
||||
"results": [
|
||||
{
|
||||
"results": Array [
|
||||
Object {
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"location": [
|
||||
"location": Array [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
],
|
||||
@@ -44,10 +44,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course",
|
||||
@@ -57,10 +57,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course",
|
||||
@@ -70,10 +70,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Text input",
|
||||
@@ -83,10 +83,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Pointing on a Picture",
|
||||
@@ -96,10 +96,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Getting Answers",
|
||||
@@ -109,10 +109,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences",
|
||||
@@ -122,10 +122,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Multiple Choice Questions",
|
||||
@@ -135,10 +135,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Numerical Input",
|
||||
@@ -148,10 +148,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Video Presentation Styles",
|
||||
@@ -161,10 +161,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 2: Get Interactive",
|
||||
"Homework - Labs and Demos",
|
||||
"Code Grader",
|
||||
@@ -174,10 +174,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Interactive Questions",
|
||||
@@ -187,10 +187,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences",
|
||||
@@ -200,10 +200,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Discussion Forums",
|
||||
@@ -213,10 +213,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Overall Grade Performance",
|
||||
@@ -226,10 +226,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
@@ -239,10 +239,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 3: Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
"Homework - Find Your Study Buddy",
|
||||
@@ -252,10 +252,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Be Social",
|
||||
@@ -265,10 +265,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"EdX Exams",
|
||||
@@ -278,10 +278,10 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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": [
|
||||
"location": Array [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"When Are Your Exams? ",
|
||||
@@ -291,7 +291,7 @@ exports[`mapSearchResponse when the response is correct should match snapshot 1`
|
||||
"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,
|
||||
|
||||
@@ -2,84 +2,79 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchOpenAction: {
|
||||
id: 'learn.coursewareSearch.openAction',
|
||||
id: 'learn.coursewareSerch.openAction',
|
||||
defaultMessage: 'Search within this course',
|
||||
description: 'Aria-label for a button that will pop up Courseware Search.',
|
||||
},
|
||||
contentSearchButton: {
|
||||
id: 'learn.coursewareSearch.contentSearchButton',
|
||||
defaultMessage: 'Content search',
|
||||
description: 'Text for a button that will pop up Courseware Search.',
|
||||
},
|
||||
searchSubmitLabel: {
|
||||
id: 'learn.coursewareSearch.submitLabel',
|
||||
id: 'learn.coursewareSerch.submitLabel',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Button label that will submit Courseware Search.',
|
||||
},
|
||||
searchClearAction: {
|
||||
id: 'learn.coursewareSearch.clearAction',
|
||||
id: 'learn.coursewareSerch.clearAction',
|
||||
defaultMessage: 'Clear search',
|
||||
description: 'Button label that will the current Courseware Search input.',
|
||||
},
|
||||
searchCloseAction: {
|
||||
id: 'learn.coursewareSearch.closeAction',
|
||||
id: 'learn.coursewareSerch.closeAction',
|
||||
defaultMessage: 'Close the search form',
|
||||
description: 'Aria-label for a button that will close Courseware Search.',
|
||||
},
|
||||
searchModuleTitle: {
|
||||
id: 'learn.coursewareSearch.searchModuleTitle',
|
||||
id: 'learn.coursewareSerch.searchModuleTitle',
|
||||
defaultMessage: 'Search this course',
|
||||
description: 'Title for the Courseware Search module.',
|
||||
},
|
||||
searchBarPlaceholderText: {
|
||||
id: 'learn.coursewareSearch.searchBarPlaceholderText',
|
||||
id: 'learn.coursewareSerch.searchBarPlaceholderText',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Placeholder text for the Courseware Search input control',
|
||||
},
|
||||
loading: {
|
||||
id: 'learn.coursewareSearch.loading',
|
||||
id: 'learn.coursewareSerch.loading',
|
||||
defaultMessage: 'Searching...',
|
||||
description: 'Screen reader text to use on the spinner while the search is performing.',
|
||||
},
|
||||
searchResultsNone: {
|
||||
id: 'learn.coursewareSearch.searchResultsNone',
|
||||
id: 'learn.coursewareSerch.searchResultsNone',
|
||||
defaultMessage: 'No results found.',
|
||||
description: 'Text to show when the Courseware Search found no results matching the criteria.',
|
||||
},
|
||||
searchResultsLabel: {
|
||||
id: 'learn.coursewareSearch.searchResultsLabel',
|
||||
id: 'learn.coursewareSerch.searchResultsLabel',
|
||||
defaultMessage: 'Results for "{keyword}":',
|
||||
description: 'Text to show above the search results response list.',
|
||||
},
|
||||
searchResultsError: {
|
||||
id: 'learn.coursewareSearch.searchResultsError',
|
||||
id: 'learn.coursewareSerch.searchResultsError',
|
||||
defaultMessage: 'There was an error on the search process. Please try again in a few minutes. If the problem persists, please contact the support team.',
|
||||
description: 'Error message to show to the users when there\'s an error with the endpoint or the returned payload format.',
|
||||
},
|
||||
|
||||
// These are translations for labeling the filters
|
||||
'filter:all': {
|
||||
id: 'learn.coursewareSearch.filter:all',
|
||||
id: 'learn.coursewareSerch.filter:all',
|
||||
defaultMessage: 'All content',
|
||||
description: 'Label for the search results filter that shows all content (no filter).',
|
||||
},
|
||||
'filter:text': {
|
||||
id: 'learn.coursewareSearch.filter:text',
|
||||
id: 'learn.coursewareSerch.filter:text',
|
||||
defaultMessage: 'Text',
|
||||
description: 'Label for the search results filter that shows results with text content.',
|
||||
},
|
||||
'filter:video': {
|
||||
id: 'learn.coursewareSearch.filter:video',
|
||||
id: 'learn.coursewareSerch.filter:video',
|
||||
defaultMessage: 'Video',
|
||||
description: 'Label for the search results filter that shows results with video content.',
|
||||
},
|
||||
'filter:sequence': {
|
||||
id: 'learn.coursewareSearch.filter:sequence',
|
||||
id: 'learn.coursewareSerch.filter:sequence',
|
||||
defaultMessage: 'Section',
|
||||
description: 'Label for the search results filter that shows results with section content.',
|
||||
},
|
||||
'filter:other': {
|
||||
id: 'learn.coursewareSearch.filter:other',
|
||||
id: 'learn.coursewareSerch.filter:other',
|
||||
defaultMessage: 'Other',
|
||||
description: 'Label for the search results filter that shows results with other content.',
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@ Factory.define('courseHomeMetadata')
|
||||
.option('host', 'http://localhost:18000')
|
||||
.attrs({
|
||||
title: 'Demonstration Course',
|
||||
is_new_discussion_sidebar_view_enabled: false,
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
is_staff: false,
|
||||
|
||||
@@ -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`] = `
|
||||
{
|
||||
"courseHome": {
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -12,13 +12,9 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": {
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -26,12 +22,12 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": {
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -42,40 +38,39 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isNewDiscussionSidebarViewEnabled": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": [
|
||||
{
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"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 +79,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": {
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -94,10 +89,10 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"courseDateBlocks": [
|
||||
{
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
@@ -106,7 +101,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
@@ -116,7 +111,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Completed",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -125,7 +120,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -135,7 +130,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"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 +140,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
@@ -156,7 +151,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"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 +161,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
@@ -177,7 +172,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
@@ -188,7 +183,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"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 +192,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"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 +202,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -217,7 +212,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -227,7 +222,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -237,7 +232,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -247,7 +242,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -255,7 +250,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"learnerHasAccess": true,
|
||||
"title": "One Unreleased 1",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -265,7 +260,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -274,7 +269,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 1",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -283,7 +278,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 2",
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"dateType": "course-end-date",
|
||||
"description": "",
|
||||
@@ -292,7 +287,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"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 +297,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"title": "Verification Deadline",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": {
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
@@ -314,16 +309,16 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": {
|
||||
"specialExams": Object {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -351,27 +346,27 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": {
|
||||
"prerequisite_status": Object {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": {
|
||||
"examAccessToken": Object {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"download_url": "",
|
||||
"instructions": [],
|
||||
"instructions": Array [],
|
||||
"name": "",
|
||||
"rules": {},
|
||||
"rules": Object {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -382,7 +377,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": {
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
@@ -393,8 +388,8 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
||||
{
|
||||
"courseHome": {
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -404,13 +399,9 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": {
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -418,12 +409,12 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": {
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -434,40 +425,39 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isNewDiscussionSidebarViewEnabled": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": [
|
||||
{
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"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 +466,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": {
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -486,41 +476,41 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
},
|
||||
},
|
||||
},
|
||||
"outline": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"accessExpiration": null,
|
||||
"canShowUpgradeSock": false,
|
||||
"certData": {
|
||||
"certData": Object {
|
||||
"certStatus": null,
|
||||
"certWebViewUrl": null,
|
||||
"certificateAvailableDate": null,
|
||||
},
|
||||
"courseBlocks": {
|
||||
"courses": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": {
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionIds": [
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sections": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": {
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"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": [
|
||||
"sequenceIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
],
|
||||
"title": "Title of Section",
|
||||
},
|
||||
},
|
||||
"sequences": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": {
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
@@ -536,30 +526,30 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseGoals": {
|
||||
"courseGoals": Object {
|
||||
"daysPerWeek": null,
|
||||
"goalOptions": [],
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": null,
|
||||
"subscribedToReminders": null,
|
||||
"weeklyLearningGoalEnabled": false,
|
||||
},
|
||||
"courseTools": [
|
||||
{
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "https://example.com/bookmarks",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": {
|
||||
"datesBannerInfo": Object {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"datesWidget": {
|
||||
"courseDateBlocks": [],
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
},
|
||||
"enableProctoredExams": undefined,
|
||||
"enrollAlert": {
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
@@ -569,13 +559,13 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"offer": null,
|
||||
"resumeCourse": {
|
||||
"resumeCourse": Object {
|
||||
"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": {
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": "2050-01-01T12:00:00",
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -587,16 +577,16 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": {
|
||||
"specialExams": Object {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -624,27 +614,27 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": {
|
||||
"prerequisite_status": Object {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": {
|
||||
"examAccessToken": Object {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"download_url": "",
|
||||
"instructions": [],
|
||||
"instructions": Array [],
|
||||
"name": "",
|
||||
"rules": {},
|
||||
"rules": Object {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -655,7 +645,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": {
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
@@ -666,8 +656,8 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
|
||||
{
|
||||
"courseHome": {
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -677,13 +667,9 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": {
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -691,12 +677,12 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": {
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -707,40 +693,39 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isNewDiscussionSidebarViewEnabled": false,
|
||||
"isSelfPaced": false,
|
||||
"isStaff": false,
|
||||
"number": "DemoX",
|
||||
"org": "edX",
|
||||
"originalUserIsStaff": false,
|
||||
"start": "2013-02-05T05:00:00Z",
|
||||
"tabs": [
|
||||
{
|
||||
"tabs": Array [
|
||||
Object {
|
||||
"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 +734,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": {
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -759,16 +744,16 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"progress": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"accessExpiration": null,
|
||||
"certificateData": {},
|
||||
"completionSummary": {
|
||||
"certificateData": Object {},
|
||||
"completionSummary": Object {
|
||||
"completeCount": 1,
|
||||
"incompleteCount": 1,
|
||||
"lockedCount": 0,
|
||||
},
|
||||
"courseGrade": {
|
||||
"courseGrade": Object {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
@@ -779,10 +764,10 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"enrollmentMode": "audit",
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
"gradesFeatureIsPartiallyLocked": false,
|
||||
"gradingPolicy": {
|
||||
"assignmentPolicies": [
|
||||
{
|
||||
"averageGrade": "1.0000",
|
||||
"gradingPolicy": Object {
|
||||
"assignmentPolicies": Array [
|
||||
Object {
|
||||
"averageGrade": "1.00",
|
||||
"numDroppable": 1,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
@@ -790,17 +775,17 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"weightedGrade": 1,
|
||||
},
|
||||
],
|
||||
"gradeRange": {
|
||||
"gradeRange": Object {
|
||||
"pass": 0.75,
|
||||
},
|
||||
},
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionScores": [
|
||||
{
|
||||
"sectionScores": Array [
|
||||
Object {
|
||||
"displayName": "First section",
|
||||
"subsections": [
|
||||
{
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
|
||||
"displayName": "First subsection",
|
||||
@@ -809,16 +794,16 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"numPointsEarned": 0,
|
||||
"numPointsPossible": 3,
|
||||
"percentGraded": 0,
|
||||
"problemScores": [
|
||||
{
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
@@ -829,18 +814,18 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Object {
|
||||
"displayName": "Second section",
|
||||
"subsections": [
|
||||
{
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"assignmentType": "Homework",
|
||||
"displayName": "Second subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"numPointsEarned": 1,
|
||||
"numPointsPossible": 1,
|
||||
"percentGraded": 1,
|
||||
"problemScores": [
|
||||
{
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"earned": 1,
|
||||
"possible": 1,
|
||||
},
|
||||
@@ -854,7 +839,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
],
|
||||
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
|
||||
"userHasPassingGrade": false,
|
||||
"verificationData": {
|
||||
"verificationData": Object {
|
||||
"link": null,
|
||||
"status": "none",
|
||||
"statusDate": null,
|
||||
@@ -863,16 +848,16 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": {
|
||||
"specialExams": Object {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -900,27 +885,27 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": {
|
||||
"prerequisite_status": Object {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": {
|
||||
"examAccessToken": Object {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"download_url": "",
|
||||
"instructions": [],
|
||||
"instructions": Array [],
|
||||
"name": "",
|
||||
"rules": {},
|
||||
"rules": Object {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -931,7 +916,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": {
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
|
||||
@@ -18,7 +18,7 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
|
||||
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
||||
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
||||
// exists in edx-platform.
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
|
||||
@@ -5,7 +5,6 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
@@ -195,24 +194,19 @@ const OutlineTab = ({ intl }) => {
|
||||
/>
|
||||
)}
|
||||
<CourseTools />
|
||||
<PluginSlot
|
||||
id="outline_tab_notifications_slot"
|
||||
pluginProps={{ courseId }}
|
||||
>
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
</PluginSlot>
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
<CourseDates />
|
||||
<CourseHandouts />
|
||||
</div>
|
||||
|
||||
@@ -132,16 +132,6 @@ describe('Outline Tab', () => {
|
||||
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('includes outline_tab_notifications_slot', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(screen.getByTestId('outline_tab_notifications_slot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles expand/collapse all button click', async () => {
|
||||
await fetchAndRender();
|
||||
// Button renders as "Expand All"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -95,7 +96,7 @@ const SequenceLink = ({
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-success mt-1"
|
||||
aria-hidden={complete}
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.completedAssignment)}
|
||||
/>
|
||||
) : (
|
||||
@@ -103,7 +104,7 @@ const SequenceLink = ({
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-gray-400 mt-1"
|
||||
aria-hidden={complete}
|
||||
aria-hidden="true"
|
||||
title={intl.formatMessage(messages.incompleteAssignment)}
|
||||
/>
|
||||
)}
|
||||
@@ -117,14 +118,14 @@ const SequenceLink = ({
|
||||
</div>
|
||||
</div>
|
||||
{hideFromTOC && (
|
||||
<div className="row w-100 my-2 mx-4 pl-3">
|
||||
<span className="small d-flex">
|
||||
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
|
||||
<span data-testid="hide-from-toc-sequence-link-text">
|
||||
{intl.formatMessage(messages.hiddenSequenceLink)}
|
||||
</span>
|
||||
<div className="row w-100 my-2 mx-4 pl-3">
|
||||
<span className="small d-flex">
|
||||
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
|
||||
<span data-testid="hide-from-toc-sequence-link-text">
|
||||
{intl.formatMessage(messages.hiddenSequenceLink)}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
|
||||
@@ -19,10 +19,6 @@ const CertificateStatus = ({ intl }) => {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
entranceExamData,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
isEnrolled,
|
||||
org,
|
||||
@@ -46,8 +42,6 @@ const CertificateStatus = ({ intl }) => {
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
const entranceExamPassed = entranceExamData?.entranceExamPassed ?? null;
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
@@ -55,7 +49,6 @@ const CertificateStatus = ({ intl }) => {
|
||||
userHasPassingGrade,
|
||||
null, // CourseExitPageIsActive
|
||||
canViewCertificate,
|
||||
entranceExamPassed,
|
||||
);
|
||||
|
||||
const eventProperties = {
|
||||
|
||||
@@ -36,17 +36,17 @@
|
||||
}
|
||||
|
||||
#passing-grade-tooltip {
|
||||
background: $success-500;
|
||||
|
||||
.arrow::after {
|
||||
border-top-color: $success-500;
|
||||
}
|
||||
|
||||
background: $success-500;
|
||||
}
|
||||
|
||||
#non-passing-grade-tooltip {
|
||||
background: $accent-b;
|
||||
|
||||
.arrow::after {
|
||||
border-top-color: $accent-b;
|
||||
}
|
||||
|
||||
background: $accent-b;
|
||||
}
|
||||
|
||||
@@ -16,27 +16,23 @@ const CourseTabsNavigation = ({
|
||||
return (
|
||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="container-xl">
|
||||
<div className="nav-bar">
|
||||
<div className="nav-menu">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={url}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="search-toggle">
|
||||
<CoursewareSearchToggle />
|
||||
</div>
|
||||
</div>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="course-tabs-navigation__search-toggle">
|
||||
<CoursewareSearchToggle />
|
||||
</div>
|
||||
{show && <CoursewareSearch />}
|
||||
</div>
|
||||
|
||||
@@ -16,23 +16,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
&__search-toggle {
|
||||
position: absolute;
|
||||
top: .05rem;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-toggle {
|
||||
flex-grow: 0;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
|
||||
import { AlertList } from '@src/generic/user-messages';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { getCoursewareOutlineSidebarSettings } from '../data/selectors';
|
||||
import { Trigger as CourseOutlineTrigger } from './sidebar/sidebars/course-outline';
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import Sequence from './sequence';
|
||||
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
import Chat from './chat/Chat';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||
import SidebarTriggers from './sidebar/SidebarTriggers';
|
||||
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
|
||||
import NewSidebarTriggers from './new-sidebar/SidebarTriggers';
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import ContentTools from './content-tools';
|
||||
import Sequence from './sequence';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const Course = ({
|
||||
courseId,
|
||||
@@ -32,12 +33,11 @@ const Course = ({
|
||||
const {
|
||||
celebrations,
|
||||
isStaff,
|
||||
isNewDiscussionSidebarViewEnabled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
||||
const { enableNavigationSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
|
||||
const navigationDisabled = enableNavigationSidebar || (sequence?.navigationDisabled ?? false);
|
||||
const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR;
|
||||
const navigationDisabled = sequence?.navigationDisabled ?? false;
|
||||
|
||||
const pageTitleBreadCrumbs = [
|
||||
sequence,
|
||||
@@ -54,6 +54,7 @@ const Course = ({
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
||||
);
|
||||
const shouldDisplayTriggers = windowWidth >= breakpoints.small.minWidth;
|
||||
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
|
||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||
|
||||
@@ -68,14 +69,14 @@ const Course = ({
|
||||
));
|
||||
}, [sequenceId]);
|
||||
|
||||
const SidebarProviderComponent = isNewDiscussionSidebarViewEnabled ? NewSidebarProvider : SidebarProvider;
|
||||
const SidebarProviderComponent = enableNewSidebar === 'true' ? NewSidebarProvider : SidebarProvider;
|
||||
|
||||
return (
|
||||
<SidebarProviderComponent courseId={courseId} unitId={unitId}>
|
||||
<Helmet>
|
||||
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
<div className="position-relative d-flex align-items-xl-center mb-4 mt-1 flex-column flex-xl-row">
|
||||
<div className="position-relative d-flex align-items-center mb-4 mt-1">
|
||||
{navigationDisabled || (
|
||||
<>
|
||||
<CourseBreadcrumbs
|
||||
@@ -99,10 +100,11 @@ const Course = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="w-100 d-flex align-items-center">
|
||||
<CourseOutlineTrigger isMobileView />
|
||||
{isNewDiscussionSidebarViewEnabled ? <NewSidebarTriggers /> : <SidebarTriggers /> }
|
||||
</div>
|
||||
{shouldDisplayTriggers && (
|
||||
<>
|
||||
{enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers /> }
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertList topic="sequence" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Factory } from 'rosie';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
|
||||
import {
|
||||
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
@@ -32,6 +32,8 @@ celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
|
||||
|
||||
describe('Course', () => {
|
||||
let store;
|
||||
let getItemSpy;
|
||||
let setItemSpy;
|
||||
const mockData = {
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
@@ -50,27 +52,30 @@ 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();
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(await 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();
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
loadUnit();
|
||||
await waitFor(() => 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 () => {
|
||||
@@ -109,11 +114,9 @@ describe('Course', () => {
|
||||
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
waitFor(() => {
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
|
||||
});
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays weekly goal celebration modal', async () => {
|
||||
@@ -129,42 +132,37 @@ describe('Course', () => {
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
waitFor(() => {
|
||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
|
||||
});
|
||||
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 });
|
||||
|
||||
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');
|
||||
});
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('mt-3', { exact: true });
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
|
||||
});
|
||||
|
||||
it('handles click to open/close discussions sidebar', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
|
||||
const discussionsTrigger = screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
expect(discussionsTrigger).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(discussionsTrigger);
|
||||
|
||||
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(discussionsTrigger);
|
||||
|
||||
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
});
|
||||
await expect(discussionsSideBar).toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(discussionsTrigger);
|
||||
});
|
||||
await expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('displays discussions sidebar when unit changes', async () => {
|
||||
@@ -183,9 +181,9 @@ describe('Course', () => {
|
||||
const { rerender } = render(<Course {...testData} />, { store: testStore });
|
||||
loadUnit();
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.findByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.findByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
rerender(null);
|
||||
@@ -193,13 +191,10 @@ describe('Course', () => {
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
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');
|
||||
});
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('renders course breadcrumbs as expected', async () => {
|
||||
@@ -209,9 +204,7 @@ describe('Course', () => {
|
||||
{ type: 'vertical' },
|
||||
{ courseId: courseMetadata.id },
|
||||
));
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, enableNavigationSidebar: { enable_navigation_sidebar: false },
|
||||
}, false);
|
||||
const testStore = await initializeTestStore({ courseMetadata, unitBlocks }, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
@@ -223,14 +216,10 @@ 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.
|
||||
waitFor(() => {
|
||||
expect(screen.findByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
|
||||
expect(screen.findByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
|
||||
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes handlers to the sequence', async () => {
|
||||
@@ -259,16 +248,14 @@ describe('Course', () => {
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
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));
|
||||
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));
|
||||
|
||||
// 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', () => {
|
||||
@@ -288,7 +275,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
waitFor(() => expect(screen.findByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with passing score', async () => {
|
||||
@@ -322,7 +309,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
waitFor(() => expect(screen.findByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with non-passing score', async () => {
|
||||
@@ -356,7 +343,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
waitFor(() => expect(screen.findByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,7 +362,7 @@ describe('Course', () => {
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
waitFor(() => expect(chat).toBeInTheDocument());
|
||||
await expect(chat).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display chat when screen is too narrow (mobile)', async () => {
|
||||
|
||||
@@ -154,7 +154,7 @@ const CourseBreadcrumbs = ({
|
||||
}, [courseStatus, sequenceStatus, allSequencesInSections]);
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10 mb-3">
|
||||
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10">
|
||||
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
|
||||
<li className="list-unstyled col-auto m-0 p-0">
|
||||
<Link
|
||||
|
||||
@@ -5,7 +5,6 @@ import PropTypes from 'prop-types';
|
||||
import { Xpert } from '@edx/frontend-lib-learning-assistant';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { VERIFIED_MODES } from '@src/constants';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const Chat = ({
|
||||
@@ -21,10 +20,21 @@ const Chat = ({
|
||||
} = useSelector(state => state.specialExams);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
|
||||
const VERIFIED_MODES = [
|
||||
'professional',
|
||||
'verified',
|
||||
'no-id-professional',
|
||||
'credit',
|
||||
'masters',
|
||||
'executive-education',
|
||||
'paid-executive-education',
|
||||
'paid-bootcamp',
|
||||
];
|
||||
|
||||
const hasVerifiedEnrollment = (
|
||||
enrollmentMode !== null
|
||||
&& enrollmentMode !== undefined
|
||||
&& VERIFIED_MODES.includes(enrollmentMode)
|
||||
&& [...VERIFIED_MODES].some(mode => mode === enrollmentMode)
|
||||
);
|
||||
|
||||
const validDates = () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -33,14 +32,9 @@ 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;
|
||||
}
|
||||
@@ -79,7 +73,6 @@ function GetCourseExitNavigation(courseId, intl) {
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
entranceExamData: { entranceExamPassed },
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
const { canViewCertificate } = useModel('courseHomeMeta', courseId);
|
||||
const exitMode = getCourseExitMode(
|
||||
@@ -89,15 +82,8 @@ function GetCourseExitNavigation(courseId, intl) {
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
canViewCertificate,
|
||||
entranceExamPassed,
|
||||
);
|
||||
|
||||
/** 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));
|
||||
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;
|
||||
|
||||
let exitText;
|
||||
switch (exitMode) {
|
||||
|
||||
@@ -6,8 +6,7 @@ import { SIDEBARS } from './sidebars';
|
||||
const Sidebar = () => {
|
||||
const { currentSidebar, isDiscussionbarAvailable, isNotificationbarAvailable } = useContext(SidebarContext);
|
||||
|
||||
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)
|
||||
|| !SIDEBARS[currentSidebar]) { return null; }
|
||||
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)) { return null; }
|
||||
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
|
||||
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { WIDGETS } from '../../../constants';
|
||||
import WIDGETS from './constants';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
|
||||
@@ -18,20 +18,11 @@ const SidebarProvider = ({
|
||||
unitId,
|
||||
children,
|
||||
}) => {
|
||||
const { verifiedMode } = useModel('courseHomeMeta', courseId);
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true';
|
||||
const sidebarKey = `sidebar.${courseId}`;
|
||||
|
||||
let initialSidebar = shouldDisplayFullScreen && sidebarKey in localStorage ? getLocalStorage(sidebarKey)
|
||||
: SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID;
|
||||
|
||||
if (!shouldDisplayFullScreen && isInitiallySidebarOpen) {
|
||||
initialSidebar = SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID;
|
||||
}
|
||||
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true')
|
||||
? SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID : null;
|
||||
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [hideDiscussionbar, setHideDiscussionbar] = useState(false);
|
||||
@@ -39,6 +30,8 @@ const SidebarProvider = ({
|
||||
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(
|
||||
getLocalStorage(`upgradeNotificationCurrentState.${courseId}`),
|
||||
);
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
const { verifiedMode } = useModel('courseHomeMeta', courseId);
|
||||
const isDiscussionbarAvailable = (topic?.id && topic?.enabledInContext) || false;
|
||||
const isNotificationbarAvailable = !isEmpty(verifiedMode);
|
||||
|
||||
@@ -50,9 +43,7 @@ const SidebarProvider = ({
|
||||
useEffect(() => {
|
||||
setHideDiscussionbar(!isDiscussionbarAvailable);
|
||||
setHideNotificationbar(!isNotificationbarAvailable);
|
||||
if (initialSidebar && currentSidebar !== initialSidebar) {
|
||||
setCurrentSidebar(SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID);
|
||||
}
|
||||
setCurrentSidebar(SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID);
|
||||
}, [unitId, topic]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,39 +52,16 @@ 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));
|
||||
setLocalStorage(sidebarKey, sidebarId);
|
||||
}, []);
|
||||
|
||||
const handleSidebarToggle = useCallback((sidebarId) => {
|
||||
setCurrentSidebar(prevSidebar => (sidebarId === prevSidebar ? null : sidebarId));
|
||||
setHideDiscussionbar(!isDiscussionbarAvailable);
|
||||
setHideNotificationbar(!isNotificationbarAvailable);
|
||||
setLocalStorage(sidebarKey, sidebarId === currentSidebar ? null : sidebarId);
|
||||
}, [currentSidebar, isDiscussionbarAvailable, isNotificationbarAvailable]);
|
||||
|
||||
const clearSidebarKeyIfWidgetsUnavailable = useCallback((widgetId) => {
|
||||
if (((!isNotificationbarAvailable || hideNotificationbar) && widgetId === WIDGETS.DISCUSSIONS)
|
||||
|| ((!isDiscussionbarAvailable || hideDiscussionbar) && widgetId === WIDGETS.NOTIFICATIONS)) {
|
||||
setLocalStorage(sidebarKey, null);
|
||||
}
|
||||
}, [isDiscussionbarAvailable, isNotificationbarAvailable, hideDiscussionbar, hideNotificationbar]);
|
||||
|
||||
const toggleSidebar = useCallback((sidebarId = null, widgetId = null) => {
|
||||
if (widgetId) {
|
||||
handleWidgetToggle(widgetId, sidebarId);
|
||||
setHideDiscussionbar(prevWidgetId => (widgetId === WIDGETS.DISCUSSIONS ? true : prevWidgetId));
|
||||
setHideNotificationbar(prevWidgetId => (widgetId === WIDGETS.NOTIFICATIONS ? true : prevWidgetId));
|
||||
} else {
|
||||
handleSidebarToggle(sidebarId);
|
||||
setCurrentSidebar(prevSidebar => (sidebarId === prevSidebar ? null : sidebarId));
|
||||
setHideDiscussionbar(!isDiscussionbarAvailable);
|
||||
setHideNotificationbar(!isNotificationbarAvailable);
|
||||
}
|
||||
|
||||
clearSidebarKeyIfWidgetsUnavailable(widgetId);
|
||||
}, [handleWidgetToggle, handleSidebarToggle, clearSidebarKeyIfWidgetsUnavailable]);
|
||||
}, [isDiscussionbarAvailable, isNotificationbarAvailable]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
toggleSidebar,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Icon, IconButton } from '@openedx/paragon';
|
||||
import { ArrowBackIos, Close } from '@openedx/paragon/icons';
|
||||
|
||||
import { useEventListener } from '../../../../generic/hooks';
|
||||
import { WIDGETS } from '../../../../constants';
|
||||
import WIDGETS from '../constants';
|
||||
import messages from '../messages';
|
||||
import SidebarContext from '../SidebarContext';
|
||||
|
||||
@@ -41,7 +41,7 @@ const SidebarBase = ({
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classNames('ml-0 ml-lg-4 h-auto align-top zindex-0', {
|
||||
className={classNames('ml-0 ml-lg-4 h-auto align-top', {
|
||||
'min-vh-100': !shouldDisplayFullScreen && allowFullHeight,
|
||||
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
|
||||
'd-none': currentSidebar !== sidebarId,
|
||||
@@ -104,7 +104,7 @@ SidebarBase.propTypes = {
|
||||
|
||||
SidebarBase.defaultProps = {
|
||||
title: '',
|
||||
width: '45rem',
|
||||
width: '50rem',
|
||||
allowFullHeight: false,
|
||||
showTitleBar: true,
|
||||
className: '',
|
||||
|
||||
6
src/courseware/course/new-sidebar/constants.js
Normal file
6
src/courseware/course/new-sidebar/constants.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const WIDGETS = {
|
||||
DISCUSSIONS: 'DISCUSSIONS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
};
|
||||
|
||||
export default WIDGETS;
|
||||
@@ -14,17 +14,19 @@ const DiscussionsNotificationsSidebar = () => {
|
||||
const { hideNotificationbar } = useContext(SidebarContext);
|
||||
|
||||
return (
|
||||
<SidebarBase
|
||||
ariaLabel={intl.formatMessage(messages.discussionNotificationTray)}
|
||||
sidebarId={ID}
|
||||
className="d-flex flex-column flex-fill overflow-auto"
|
||||
showTitleBar={false}
|
||||
showBorder={false}
|
||||
>
|
||||
<NotificationTray />
|
||||
{!hideNotificationbar && <div className="my-1.5" />}
|
||||
<DiscussionsSidebar />
|
||||
</SidebarBase>
|
||||
<div className="sticky-top vh-100">
|
||||
<SidebarBase
|
||||
ariaLabel={intl.formatMessage(messages.discussionNotificationTray)}
|
||||
sidebarId={ID}
|
||||
className="d-flex flex-column flex-fill"
|
||||
showTitleBar={false}
|
||||
showBorder={false}
|
||||
>
|
||||
<NotificationTray />
|
||||
{!hideNotificationbar && <div className="my-1.5" />}
|
||||
<DiscussionsSidebar />
|
||||
</SidebarBase>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
|
||||
@@ -25,10 +24,7 @@ const DiscussionsWidget = () => {
|
||||
return (
|
||||
<iframe
|
||||
src={`${discussionsUrl}?inContextSidebar`}
|
||||
className={classNames('d-flex w-100 flex-fill border border-light-400 rounded-sm', {
|
||||
'vh-100': !shouldDisplayFullScreen,
|
||||
'min-height-700': shouldDisplayFullScreen,
|
||||
})}
|
||||
className={classNames('d-flex w-100 flex-fill border border-light-400 rounded-sm', { 'vh-100': !shouldDisplayFullScreen })}
|
||||
title={intl.formatMessage(messages.discussionsTitle)}
|
||||
allow="clipboard-write"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { useModel } from '../../../../../../generic/model-store';
|
||||
import UpgradeNotification from '../../../../../../generic/upgrade-notification/UpgradeNotification';
|
||||
import { WIDGETS } from '../../../../../../constants';
|
||||
import WIDGETS from '../../../constants';
|
||||
import SidebarContext from '../../../SidebarContext';
|
||||
|
||||
const NotificationsWidget = () => {
|
||||
@@ -59,10 +58,6 @@ const NotificationsWidget = () => {
|
||||
verification_status: verificationStatus,
|
||||
};
|
||||
|
||||
const onToggleSidebar = () => {
|
||||
toggleSidebar(currentSidebar, WIDGETS.NOTIFICATIONS);
|
||||
};
|
||||
|
||||
// After three seconds, update notificationSeen (to hide red dot)
|
||||
useEffect(() => {
|
||||
setTimeout(onNotificationSeen, 3000);
|
||||
@@ -73,32 +68,22 @@ const NotificationsWidget = () => {
|
||||
|
||||
return (
|
||||
<div className="border border-light-400 rounded-sm" data-testid="notification-widget">
|
||||
<PluginSlot
|
||||
id="notification_widget_slot"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
notificationCurrentState: upgradeNotificationCurrentState,
|
||||
setNotificationCurrentState: setUpgradeNotificationCurrentState,
|
||||
toggleSidebar: onToggleSidebar,
|
||||
}}
|
||||
>
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
||||
toggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
</PluginSlot>
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
||||
toggleSidebar={() => toggleSidebar(currentSidebar, WIDGETS.NOTIFICATIONS)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
|
||||
@@ -49,11 +49,13 @@ describe('NotificationsWidget', () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
mergeConfig({ ENABLE_NEW_SIDEBAR: 'true' }, 'Custom app config');
|
||||
});
|
||||
|
||||
it('successfully Open/Hide sidebar tray', async () => {
|
||||
it('successfully Open/Hide sidebar tray.', async () => {
|
||||
const userVerifiedMode = Factory.build('verifiedMode');
|
||||
await setupDiscussionSidebar({ verifiedMode: userVerifiedMode, isNewDiscussionSidebarViewEnabled: true });
|
||||
|
||||
await setupDiscussionSidebar(userVerifiedMode);
|
||||
|
||||
const sidebarButton = await screen.getByRole('button', { name: /Show sidebar tray/i });
|
||||
|
||||
@@ -78,21 +80,6 @@ describe('NotificationsWidget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('includes notification_widget_slot', async () => {
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
hideNotificationbar: false,
|
||||
isNotificationbarAvailable: true,
|
||||
}}
|
||||
>
|
||||
<NotificationsWidget />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(screen.getByTestId('notification_widget_slot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgrade card', async () => {
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
@@ -105,11 +92,9 @@ describe('NotificationsWidget', () => {
|
||||
<NotificationsWidget />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
|
||||
// The Upgrade Notification should be inside the PluginSlot.
|
||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
||||
expect(UpgradeNotification).toBeInTheDocument();
|
||||
|
||||
expect(UpgradeNotification).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You have no new notifications at this time.')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -144,11 +129,7 @@ describe('NotificationsWidget', () => {
|
||||
])('successfully %s', async ({ enabledInContext, testId }) => {
|
||||
const userVerifiedMode = Factory.build('verifiedMode');
|
||||
|
||||
await setupDiscussionSidebar({
|
||||
verifiedMode: userVerifiedMode,
|
||||
enabledInContext,
|
||||
isNewDiscussionSidebarViewEnabled: true,
|
||||
});
|
||||
await setupDiscussionSidebar(userVerifiedMode, enabledInContext);
|
||||
|
||||
const sidebarButton = screen.getByRole('button', { name: /Show sidebar tray/i });
|
||||
|
||||
@@ -169,6 +150,7 @@ describe('NotificationsWidget', () => {
|
||||
});
|
||||
|
||||
it('marks notification as seen 3 seconds later', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onNotificationSeen = jest.fn();
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
@@ -183,6 +165,7 @@ describe('NotificationsWidget', () => {
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(0);
|
||||
await waitFor(() => expect(onNotificationSeen).toHaveBeenCalledTimes(1), { timeout: 3500 });
|
||||
jest.advanceTimersByTime(3000);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, {
|
||||
useEffect, useState,
|
||||
} from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
sendTrackEvent,
|
||||
@@ -9,21 +13,17 @@ import {
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
|
||||
import PageLoading from '@src/generic/PageLoading';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '@src/alerts/sequence-alerts/hooks';
|
||||
import SequenceContainerSlot from '../../../plugin-slots/SequenceContainerSlot';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '../../../alerts/sequence-alerts/hooks';
|
||||
|
||||
import { getCoursewareOutlineSidebarSettings } from '../../data/selectors';
|
||||
import CourseLicense from '../course-license';
|
||||
import Sidebar from '../sidebar/Sidebar';
|
||||
import NewSidebar from '../new-sidebar/Sidebar';
|
||||
import {
|
||||
Trigger as CourseOutlineTrigger,
|
||||
Sidebar as CourseOutlineTray,
|
||||
} from '../sidebar/sidebars/course-outline';
|
||||
import SidebarTriggers from '../sidebar/SidebarTriggers';
|
||||
import NewSidebarTriggers from '../new-sidebar/SidebarTriggers';
|
||||
import messages from './messages';
|
||||
import HiddenAfterDue from './hidden-after-due';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
@@ -38,7 +38,6 @@ const Sequence = ({
|
||||
previousSequenceHandler,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [isOpen, open, close] = useToggle();
|
||||
const {
|
||||
canAccessProctoredExams,
|
||||
license,
|
||||
@@ -46,29 +45,30 @@ const Sequence = ({
|
||||
const {
|
||||
isStaff,
|
||||
originalUserIsStaff,
|
||||
isNewDiscussionSidebarViewEnabled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const unit = useModel('units', unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
|
||||
const { enableNavigationSidebar: isEnabledOutlineSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
|
||||
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
|
||||
const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR;
|
||||
|
||||
const handleNext = () => {
|
||||
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
|
||||
const newUnitId = sequence.unitIds[nextIndex];
|
||||
handleNavigate(newUnitId);
|
||||
|
||||
if (nextIndex >= sequence.unitIds.length) {
|
||||
if (nextIndex < sequence.unitIds.length) {
|
||||
const newUnitId = sequence.unitIds[nextIndex];
|
||||
handleNavigate(newUnitId);
|
||||
} else {
|
||||
nextSequenceHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const previousIndex = sequence.unitIds.indexOf(unitId) - 1;
|
||||
const newUnitId = sequence.unitIds[previousIndex];
|
||||
handleNavigate(newUnitId);
|
||||
|
||||
if (previousIndex < 0) {
|
||||
if (previousIndex >= 0) {
|
||||
const newUnitId = sequence.unitIds[previousIndex];
|
||||
handleNavigate(newUnitId);
|
||||
} else {
|
||||
previousSequenceHandler();
|
||||
}
|
||||
};
|
||||
@@ -147,57 +147,34 @@ const Sequence = ({
|
||||
|
||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
|
||||
const renderUnitNavigation = (isAtTop) => (
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
isAtTop={isAtTop}
|
||||
onClickPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||
handlePrevious();
|
||||
}}
|
||||
onClickNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const defaultContent = (
|
||||
<>
|
||||
<div className="sequence-container d-inline-flex flex-row w-100">
|
||||
<CourseOutlineTrigger />
|
||||
<CourseOutlineTray />
|
||||
<div className="sequence w-100">
|
||||
{!isEnabledOutlineSidebar && (
|
||||
<div className="sequence-navigation-container">
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
nextHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
{...{
|
||||
nextSequenceHandler,
|
||||
handleNavigate,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
|
||||
<div className="sequence-navigation-container">
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
className="mb-4"
|
||||
nextHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
{shouldDisplayNotificationTriggerInSequence && (
|
||||
enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="unit-container flex-grow-1 pt-4">
|
||||
<div className="unit-container flex-grow-1">
|
||||
<SequenceContent
|
||||
courseId={courseId}
|
||||
gated={gated}
|
||||
@@ -205,12 +182,25 @@ const Sequence = ({
|
||||
unitId={unitId}
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
/>
|
||||
{unitHasLoaded && renderUnitNavigation(false)}
|
||||
{unitHasLoaded && (
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
onClickPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||
handlePrevious();
|
||||
}}
|
||||
onClickNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isNewDiscussionSidebarViewEnabled ? <NewSidebar /> : <Sidebar />}
|
||||
{enableNewSidebar === 'true' ? <NewSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
<SequenceContainerSlot courseId={courseId} unitId={unitId} />
|
||||
<div id="whole-course-translation-feedback-widget" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -224,7 +214,6 @@ const Sequence = ({
|
||||
originalUserIsStaff={originalUserIsStaff}
|
||||
canAccessProctoredExams={canAccessProctoredExams}
|
||||
>
|
||||
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
|
||||
{defaultContent}
|
||||
</SequenceExamWrapper>
|
||||
<CourseLicense license={license || undefined} />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Factory } from 'rosie';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
import {
|
||||
loadUnit, render, screen, fireEvent, waitFor, initializeTestStore, act,
|
||||
loadUnit, render, screen, fireEvent, waitFor, initializeTestStore,
|
||||
} from '../../../setupTest';
|
||||
import SidebarContext from '../sidebar/SidebarContext';
|
||||
import Sequence from './Sequence';
|
||||
@@ -17,14 +18,12 @@ jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
|
||||
|
||||
describe('Sequence', () => {
|
||||
let mockData;
|
||||
let defaultContextValue;
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
|
||||
'block',
|
||||
{ type: 'vertical' },
|
||||
{ courseId: courseMetadata.id },
|
||||
));
|
||||
const enableNavigationSidebar = { enable_navigation_sidebar: false };
|
||||
|
||||
beforeAll(async () => {
|
||||
const store = await initializeTestStore({ courseMetadata, unitBlocks });
|
||||
@@ -39,29 +38,12 @@ describe('Sequence', () => {
|
||||
toggleNotificationTray: () => {},
|
||||
setNotificationStatus: () => {},
|
||||
};
|
||||
defaultContextValue = { courseId: mockData.courseId, currentSidebar: null, toggleSidebar: jest.fn() };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
global.innerWidth = breakpoints.extraLarge.minWidth;
|
||||
});
|
||||
|
||||
const SidebarWrapper = ({ contextValue = defaultContextValue, overrideData = {} }) => (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<Sequence {...({ ...mockData, ...overrideData })} />
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
|
||||
SidebarWrapper.defaultProps = {
|
||||
contextValue: defaultContextValue,
|
||||
overrideData: {},
|
||||
};
|
||||
|
||||
SidebarWrapper.propTypes = {
|
||||
contextValue: PropTypes.shape({}),
|
||||
overrideData: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
it('renders correctly without data', async () => {
|
||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||
render(
|
||||
@@ -92,31 +74,25 @@ describe('Sequence', () => {
|
||||
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
|
||||
)];
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata,
|
||||
unitBlocks,
|
||||
sequenceBlocks,
|
||||
sequenceMetadata,
|
||||
enableNavigationSidebar: { enable_navigation_sidebar: true },
|
||||
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
|
||||
}, false);
|
||||
const { container } = render(
|
||||
<SidebarWrapper overrideData={{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
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);
|
||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(3);
|
||||
// `Active` and `Next` buttons.
|
||||
expect(screen.getAllByRole('link').length).toEqual(2);
|
||||
|
||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||
const unitContainer = container.querySelector('.unit-container');
|
||||
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 () => {
|
||||
@@ -131,7 +107,7 @@ describe('Sequence', () => {
|
||||
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
|
||||
)];
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata, enableNavigationSidebar,
|
||||
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
|
||||
}, false);
|
||||
render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
@@ -158,22 +134,18 @@ describe('Sequence', () => {
|
||||
});
|
||||
|
||||
it('handles loading unit', async () => {
|
||||
render(<SidebarWrapper />, { wrapWithRouter: true });
|
||||
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);
|
||||
render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// `Previous`, `Bookmark` and `Close Tray` buttons
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||
// Renders `Next` button plus one button for each unit.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
|
||||
|
||||
loadUnit();
|
||||
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();
|
||||
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);
|
||||
});
|
||||
|
||||
describe('sequence and unit navigation buttons', () => {
|
||||
@@ -189,9 +161,7 @@ describe('Sequence', () => {
|
||||
)];
|
||||
|
||||
beforeAll(async () => {
|
||||
testStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, sequenceBlocks, enableNavigationSidebar,
|
||||
}, false);
|
||||
testStore = await initializeTestStore({ courseMetadata, unitBlocks, sequenceBlocks }, false);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -204,34 +174,32 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[1].id,
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await 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(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
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(3);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,32 +210,31 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
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',
|
||||
});
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
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',
|
||||
});
|
||||
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(3);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: unitBlocks.length,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,23 +248,20 @@ describe('Sequence', () => {
|
||||
previousSequenceHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('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(3);
|
||||
});
|
||||
|
||||
it('handles the `Previous` buttons for the first unit in the first sequence', async () => {
|
||||
@@ -308,17 +272,15 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /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).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles the `Next` buttons for the last unit in the last sequence', async () => {
|
||||
@@ -329,17 +291,15 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
await 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).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles the navigation buttons for empty sequence', async () => {
|
||||
@@ -362,11 +322,7 @@ describe('Sequence', () => {
|
||||
{ courseId: courseMetadata.id, unitBlocks: block.children.length ? unitBlocks : [], sequenceBlock: block },
|
||||
));
|
||||
const innerTestStore = await initializeTestStore({
|
||||
courseMetadata,
|
||||
unitBlocks,
|
||||
sequenceBlocks: testSequenceBlocks,
|
||||
sequenceMetadata: testSequenceMetadata,
|
||||
enableNavigationSidebar,
|
||||
courseMetadata, unitBlocks, sequenceBlocks: testSequenceBlocks, sequenceMetadata: testSequenceMetadata,
|
||||
}, false);
|
||||
const testData = {
|
||||
...mockData,
|
||||
@@ -377,44 +333,55 @@ describe('Sequence', () => {
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
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).not.toHaveBeenCalled();
|
||||
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(2);
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
|
||||
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',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.course.upgrade.old_sidebar.notifications', {
|
||||
course_end: undefined,
|
||||
course_modes: undefined,
|
||||
course_start: undefined,
|
||||
courserun_key: undefined,
|
||||
enrollment_end: undefined,
|
||||
enrollment_mode: undefined,
|
||||
enrollment_start: undefined,
|
||||
is_upgrade_notification_visible: false,
|
||||
name: 'Old Sidebar Notification Tray',
|
||||
org_key: undefined,
|
||||
username: undefined,
|
||||
verification_status: undefined,
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(5, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,36 +395,43 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
unitNavigationHandler: jest.fn(),
|
||||
};
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
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',
|
||||
});
|
||||
render(<Sequence {...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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const SidebarWrapper = ({ contextValue }) => (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<Sequence {...mockData} />
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
|
||||
SidebarWrapper.propTypes = {
|
||||
contextValue: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
|
||||
waitFor(async () => expect(await screen.findByText('Notifications')).toBeInTheDocument());
|
||||
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 });
|
||||
act(async () => {
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalled();
|
||||
});
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not render notification tray in sequence by default if in responsive view', async () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -36,7 +35,6 @@ const ContentIFrame = ({
|
||||
elementId,
|
||||
onLoaded,
|
||||
title,
|
||||
courseId,
|
||||
}) => {
|
||||
const {
|
||||
handleIFrameLoad,
|
||||
@@ -84,17 +82,7 @@ const ContentIFrame = ({
|
||||
return (
|
||||
<>
|
||||
{(shouldShowContent && !hasLoaded) && (
|
||||
showError ? <ErrorPage /> : (
|
||||
<PluginSlot
|
||||
id="content_iframe_loader_slot"
|
||||
pluginProps={{
|
||||
defaultLoaderComponent: <PageLoading srMessage={loadingMessage} />,
|
||||
courseId,
|
||||
}}
|
||||
>
|
||||
<PageLoading srMessage={loadingMessage} />
|
||||
</PluginSlot>
|
||||
)
|
||||
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
|
||||
)}
|
||||
{shouldShowContent && (
|
||||
<div className="unit-iframe-wrapper">
|
||||
@@ -136,13 +124,11 @@ 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;
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { Suspense } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import PageLoading from '@src/generic/PageLoading';
|
||||
@@ -25,24 +24,19 @@ const UnitSuspense = ({
|
||||
meta.contentTypeGatingEnabled && unit.containsContentTypeGatedContent
|
||||
);
|
||||
|
||||
const suspenseComponent = (message, Component) => (
|
||||
<Suspense fallback={<PageLoading srMessage={formatMessage(message)} />}>
|
||||
<Component courseId={courseId} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayContentGating && (
|
||||
<Suspense fallback={<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />}>
|
||||
<PluginSlot
|
||||
id="gated_unit_content_message_slot"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
}}
|
||||
>
|
||||
<LockPaywall courseId={courseId} />
|
||||
</PluginSlot>
|
||||
</Suspense>
|
||||
suspenseComponent(messages.loadingLockedContent, LockPaywall)
|
||||
)}
|
||||
{shouldDisplayHonorCode && (
|
||||
<Suspense fallback={<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />}>
|
||||
<HonorCode courseId={courseId} />
|
||||
</Suspense>
|
||||
suspenseComponent(messages.loadingHonorCode, HonorCode)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('UnitSuspense component', () => {
|
||||
describe('output', () => {
|
||||
describe('LockPaywall', () => {
|
||||
const testNoPaywall = () => {
|
||||
it('does not display LockPaywall', () => {
|
||||
it('does not display LockPaywal', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
expect(el.instance.findByType(LockPaywall).length).toEqual(0);
|
||||
});
|
||||
@@ -79,9 +79,8 @@ describe('UnitSuspense component', () => {
|
||||
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
const [component] = el.instance.findByType(LockPaywall);
|
||||
expect(component.parent.type).toEqual('PluginSlot');
|
||||
expect(component.parent.parent.type).toEqual('Suspense');
|
||||
expect(component.parent.parent.props.fallback)
|
||||
expect(component.parent.type).toEqual('Suspense');
|
||||
expect(component.parent.props.fallback)
|
||||
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />);
|
||||
expect(component.props.courseId).toEqual(props.courseId);
|
||||
});
|
||||
|
||||
@@ -28,10 +28,14 @@ exports[`Unit component output snapshot: not bookmarked, do not show content 1`]
|
||||
>
|
||||
unit-title
|
||||
</h3>
|
||||
<UnitTitleSlot
|
||||
courseId="test-course-id"
|
||||
unitId="test-props-id"
|
||||
unitTitle="unit-title"
|
||||
<PluginSlot
|
||||
id="unit_title_plugin"
|
||||
pluginProps={
|
||||
Object {
|
||||
"courseId": "test-course-id",
|
||||
"unitId": "test-props-id",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
@@ -49,7 +53,6 @@ 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..."
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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';
|
||||
@@ -52,6 +50,12 @@ const useIFrameBehavior = ({
|
||||
if (type === messageTypes.resize) {
|
||||
setIframeHeight(payload.height);
|
||||
|
||||
// We observe exit from the video xblock fullscreen mode
|
||||
// and scroll to the previously saved scroll position
|
||||
if (windowTopOffset !== null) {
|
||||
window.scrollTo(0, Number(windowTopOffset));
|
||||
}
|
||||
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
@@ -59,12 +63,6 @@ const useIFrameBehavior = ({
|
||||
}
|
||||
}
|
||||
} else if (type === messageTypes.videoFullScreen) {
|
||||
// We observe exit from the video xblock fullscreen mode
|
||||
// and scroll to the previously saved scroll position
|
||||
if (!payload.open && windowTopOffset !== null) {
|
||||
window.scrollTo(0, Number(windowTopOffset));
|
||||
}
|
||||
|
||||
// We listen for this message from LMS to know when we need to
|
||||
// save or reset scroll position on toggle video xblock fullscreen mode
|
||||
setWindowTopOffset(payload.open ? window.scrollY : null);
|
||||
@@ -86,49 +84,6 @@ 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
|
||||
@@ -139,10 +94,6 @@ const useIFrameBehavior = ({
|
||||
const handleIFrameLoad = () => {
|
||||
if (!hasLoaded) {
|
||||
setShowError(true);
|
||||
sendTrackEvent('edx.bi.error.learning.iframe_load_failed', {
|
||||
iframeUrl,
|
||||
unitId: id,
|
||||
});
|
||||
logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', {
|
||||
iframeUrl,
|
||||
});
|
||||
@@ -154,11 +105,6 @@ const useIFrameBehavior = ({
|
||||
};
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setIframeHeight(0);
|
||||
setHasLoaded(false);
|
||||
}, [iframeUrl]);
|
||||
|
||||
return {
|
||||
iframeHeight,
|
||||
handleIFrameLoad,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { fetchCourse } from '@src/courseware/data';
|
||||
import { processEvent } from '@src/course-home/data/thunks';
|
||||
import { useEventListener } from '@src/generic/hooks';
|
||||
@@ -18,8 +17,6 @@ jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useEffect: jest.fn(),
|
||||
@@ -30,11 +27,6 @@ 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', () => ({
|
||||
@@ -69,10 +61,7 @@ const dispatch = jest.fn();
|
||||
useDispatch.mockReturnValue(dispatch);
|
||||
|
||||
const postMessage = jest.fn();
|
||||
const frame = {
|
||||
contentWindow: { postMessage },
|
||||
getBoundingClientRect: jest.fn(() => ({ top: 100 })),
|
||||
};
|
||||
const frame = { contentWindow: { postMessage } };
|
||||
const mockGetElementById = jest.fn(() => frame);
|
||||
const testHash = '#test-hash';
|
||||
|
||||
@@ -95,10 +84,6 @@ 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();
|
||||
@@ -169,9 +154,6 @@ describe('useIFrameBehavior hook', () => {
|
||||
const resizeMessage = (height = 23) => ({
|
||||
data: { type: messageTypes.resize, payload: { height } },
|
||||
});
|
||||
const videoFullScreenMessage = (open = false) => ({
|
||||
data: { type: messageTypes.videoFullScreen, payload: { open } },
|
||||
});
|
||||
const testSetIFrameHeight = (height = 23) => {
|
||||
const { cb } = useEventListener.mock.calls[0][1];
|
||||
cb(resizeMessage(height));
|
||||
@@ -227,7 +209,7 @@ describe('useIFrameBehavior hook', () => {
|
||||
state.mockVals({ ...defaultStateVals, windowTopOffset });
|
||||
hook = useIFrameBehavior(props);
|
||||
const { cb } = useEventListener.mock.calls[0][1];
|
||||
cb(videoFullScreenMessage());
|
||||
cb(resizeMessage());
|
||||
expect(window.scrollTo).toHaveBeenCalledWith(0, windowTopOffset);
|
||||
});
|
||||
it('does not scroll if towverticalp offset is not set', () => {
|
||||
@@ -277,53 +259,6 @@ 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', () => {
|
||||
@@ -333,16 +268,6 @@ describe('useIFrameBehavior hook', () => {
|
||||
expect(state.setState.showError).toHaveBeenCalledWith(true);
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
it('sends track event if has not loaded', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
hook.handleIFrameLoad();
|
||||
const eventName = 'edx.bi.error.learning.iframe_load_failed';
|
||||
const eventProperties = {
|
||||
unitId: props.id,
|
||||
iframeUrl: props.iframeUrl,
|
||||
};
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(eventName, eventProperties);
|
||||
});
|
||||
it('does not set/log errors if loaded', () => {
|
||||
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
||||
hook = useIFrameBehavior(props);
|
||||
@@ -350,12 +275,6 @@ describe('useIFrameBehavior hook', () => {
|
||||
expect(state.setState.showError).not.toHaveBeenCalled();
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
it('does not send track event if loaded', () => {
|
||||
state.mockVals({ ...defaultStateVals, hasLoaded: true });
|
||||
hook = useIFrameBehavior(props);
|
||||
hook.handleIFrameLoad();
|
||||
expect(sendTrackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it('registers an event handler to process fetchCourse events.', () => {
|
||||
hook = useIFrameBehavior(props);
|
||||
hook.handleIFrameLoad();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -8,6 +7,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { usePluginsCallback } from '@src/generic/plugin-store';
|
||||
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import BookmarkButton from '../../bookmark/BookmarkButton';
|
||||
import messages from '../messages';
|
||||
import ContentIFrame from './ContentIFrame';
|
||||
@@ -15,7 +15,6 @@ import UnitSuspense from './UnitSuspense';
|
||||
import { modelKeys, views } from './constants';
|
||||
import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
|
||||
import { getIFrameUrl } from './urls';
|
||||
import UnitTitleSlot from '../../../../plugin-slots/UnitTitleSlot';
|
||||
|
||||
const Unit = ({
|
||||
courseId,
|
||||
@@ -24,7 +23,6 @@ const Unit = ({
|
||||
id,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { authenticatedUser } = React.useContext(AppContext);
|
||||
const examAccess = useExamAccess({ id });
|
||||
const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id });
|
||||
@@ -37,7 +35,6 @@ const Unit = ({
|
||||
view,
|
||||
format,
|
||||
examAccess,
|
||||
jumpToId: searchParams.get('jumpToId'),
|
||||
}));
|
||||
|
||||
const iframeUrl = getUrl();
|
||||
@@ -46,7 +43,13 @@ const Unit = ({
|
||||
<div className="unit">
|
||||
<div className="mb-0">
|
||||
<h3 className="h3">{unit.title}</h3>
|
||||
<UnitTitleSlot courseId={courseId} unitId={id} unitTitle={unit.title} />
|
||||
<PluginSlot
|
||||
id="unit_title_plugin"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
unitId: id,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
|
||||
<BookmarkButton
|
||||
@@ -63,7 +66,6 @@ const Unit = ({
|
||||
onLoaded={onLoaded}
|
||||
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
|
||||
title={unit.title}
|
||||
courseId={courseId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -15,7 +14,6 @@ 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');
|
||||
@@ -84,11 +82,7 @@ when(useModel)
|
||||
|
||||
let el;
|
||||
describe('Unit component', () => {
|
||||
const searchParams = { get: (prop) => prop };
|
||||
const setSearchParams = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useSearchParams.mockImplementation(() => [searchParams, setSearchParams]);
|
||||
jest.clearAllMocks();
|
||||
el = shallow(<Unit {...props} />);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { stringifyUrl } from 'query-string';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
export const iframeParams = {
|
||||
show_title: 0,
|
||||
@@ -12,20 +12,15 @@ export const getIFrameUrl = ({
|
||||
view,
|
||||
format,
|
||||
examAccess,
|
||||
jumpToId,
|
||||
}) => {
|
||||
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
|
||||
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.
|
||||
const params = stringify({
|
||||
...iframeParams,
|
||||
view,
|
||||
...(format && { format }),
|
||||
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
|
||||
});
|
||||
return `${xblockUrl}?${params}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { stringifyUrl } from 'query-string';
|
||||
import { stringify } from 'query-string';
|
||||
import { getIFrameUrl, iframeParams } from './urls';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('query-string', () => ({
|
||||
stringifyUrl: jest.fn((arg) => ({ stringifyUrl: arg })),
|
||||
stringify: jest.fn((...args) => ({ stringify: args })),
|
||||
}));
|
||||
|
||||
const config = { LMS_BASE_URL: 'test-lms-url' };
|
||||
@@ -21,43 +21,41 @@ const props = {
|
||||
|
||||
describe('urls module getIFrameUrl', () => {
|
||||
test('format provided, exam access and token available', () => {
|
||||
const url = stringifyUrl({
|
||||
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
|
||||
query: {
|
||||
...iframeParams,
|
||||
view: props.view,
|
||||
format: props.format,
|
||||
exam_access: props.examAccess.accessToken,
|
||||
},
|
||||
const params = stringify({
|
||||
...iframeParams,
|
||||
view: props.view,
|
||||
format: props.format,
|
||||
exam_access: props.examAccess.accessToken,
|
||||
});
|
||||
expect(getIFrameUrl(props)).toEqual(url);
|
||||
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
|
||||
});
|
||||
test('no format provided, exam access blocked', () => {
|
||||
const url = stringifyUrl({
|
||||
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
|
||||
query: { ...iframeParams, view: props.view },
|
||||
});
|
||||
const params = stringify({ ...iframeParams, view: props.view });
|
||||
expect(getIFrameUrl({
|
||||
id: props.id,
|
||||
view: props.view,
|
||||
examAccess: { blockAccess: true },
|
||||
})).toEqual(url);
|
||||
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
|
||||
});
|
||||
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',
|
||||
test('src and dest languages provided', () => {
|
||||
const params = stringify({
|
||||
...iframeParams,
|
||||
view: props.view,
|
||||
src_lang: 'test-src-lang',
|
||||
dest_lang: 'test-dest-lang',
|
||||
});
|
||||
expect(getIFrameUrl({
|
||||
...props,
|
||||
jumpToId: 'some-xblock-id',
|
||||
})).toEqual(url);
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
isRtl,
|
||||
getLocale,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { GetCourseExitNavigation } from '../../course-exit';
|
||||
import UnitButton from './UnitButton';
|
||||
import SequenceNavigationTabs from './SequenceNavigationTabs';
|
||||
@@ -30,11 +29,6 @@ const SequenceNavigation = ({
|
||||
onNavigate,
|
||||
nextHandler,
|
||||
previousHandler,
|
||||
nextSequenceHandler,
|
||||
handleNavigate,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
}) => {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const {
|
||||
@@ -101,37 +95,17 @@ const SequenceNavigation = ({
|
||||
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
|
||||
|
||||
return navigationDisabledNextSequence || (
|
||||
<PluginSlot
|
||||
id="next_button_slot"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
disabled,
|
||||
buttonText,
|
||||
nextArrow,
|
||||
nextLink,
|
||||
shouldDisplayNotificationTriggerInSequence,
|
||||
sequenceId,
|
||||
unitId,
|
||||
nextSequenceHandler,
|
||||
handleNavigate,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
linkComponent: Link,
|
||||
}}
|
||||
<Button
|
||||
variant="link"
|
||||
className="next-btn"
|
||||
onClick={nextHandler}
|
||||
disabled={disabled}
|
||||
iconAfter={nextArrow}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : nextLink}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
className="next-btn"
|
||||
onClick={nextHandler}
|
||||
disabled={disabled}
|
||||
iconAfter={nextArrow}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : nextLink}
|
||||
>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
|
||||
</Button>
|
||||
</PluginSlot>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -152,21 +126,11 @@ SequenceNavigation.propTypes = {
|
||||
onNavigate: PropTypes.func.isRequired,
|
||||
nextHandler: PropTypes.func.isRequired,
|
||||
previousHandler: PropTypes.func.isRequired,
|
||||
close: PropTypes.func,
|
||||
open: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
handleNavigate: PropTypes.func,
|
||||
nextSequenceHandler: PropTypes.func,
|
||||
};
|
||||
|
||||
SequenceNavigation.defaultProps = {
|
||||
className: null,
|
||||
unitId: null,
|
||||
close: null,
|
||||
open: null,
|
||||
isOpen: false,
|
||||
handleNavigate: null,
|
||||
nextSequenceHandler: null,
|
||||
};
|
||||
|
||||
export default injectIntl(SequenceNavigation);
|
||||
|
||||
@@ -1,44 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import UnitButton from './UnitButton';
|
||||
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
|
||||
import useIndexOfLastVisibleChild from '../../../../generic/tabs/useIndexOfLastVisibleChild';
|
||||
import {
|
||||
useIsOnXLDesktop, useIsOnMediumDesktop, useIsOnLargeDesktop, useIsSidebarOpen,
|
||||
} from './hooks';
|
||||
|
||||
const SequenceNavigationTabs = ({
|
||||
unitIds, unitId, showCompletion, onNavigate,
|
||||
}) => {
|
||||
const isSidebarOpen = useIsSidebarOpen(unitId);
|
||||
const [
|
||||
indexOfLastVisibleChild,
|
||||
containerRef,
|
||||
invisibleStyle,
|
||||
] = useIndexOfLastVisibleChild(isSidebarOpen);
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const isOnLargeDesktop = useIsOnLargeDesktop();
|
||||
const isOnMediumDesktop = useIsOnMediumDesktop();
|
||||
const shouldDisplayDropdown = indexOfLastVisibleChild === -1 || indexOfLastVisibleChild < unitIds.length - 1;
|
||||
] = useIndexOfLastVisibleChild();
|
||||
const shouldDisplayDropdown = indexOfLastVisibleChild === -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ flexBasis: '100%', minWidth: 0 }}
|
||||
className={classNames({
|
||||
'navigation-tab-width-xl': isOnXLDesktop && isSidebarOpen,
|
||||
'navigation-tab-width-large': isOnLargeDesktop && isSidebarOpen,
|
||||
'navigation-tab-width-medium': isOnMediumDesktop && isSidebarOpen,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="sequence-navigation-tabs-container"
|
||||
>
|
||||
<div style={{ flexBasis: '100%', minWidth: 0 }}>
|
||||
<div className="sequence-navigation-tabs-container" ref={containerRef}>
|
||||
<div
|
||||
className="sequence-navigation-tabs d-flex flex-grow-1"
|
||||
style={shouldDisplayDropdown ? invisibleStyle : null}
|
||||
ref={containerRef}
|
||||
>
|
||||
{unitIds.map(buttonUnitId => (
|
||||
<UnitButton
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@openedx/paragon';
|
||||
@@ -21,7 +21,6 @@ const UnitNavigation = ({
|
||||
unitId,
|
||||
onClickPrevious,
|
||||
onClickNext,
|
||||
isAtTop,
|
||||
}) => {
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
@@ -34,7 +33,7 @@ const UnitNavigation = ({
|
||||
return (
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
className="previous-button mr-sm-2 d-flex align-items-center justify-content-center"
|
||||
className="previous-button mr-2 d-flex align-items-center justify-content-center"
|
||||
disabled={disabled}
|
||||
onClick={onClickPrevious}
|
||||
as={disabled ? undefined : Link}
|
||||
@@ -69,7 +68,7 @@ const UnitNavigation = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('unit-navigation d-flex', { 'top-unit-navigation mb-3 w-100': isAtTop })}>
|
||||
<div className="unit-navigation d-flex">
|
||||
{renderPreviousButton()}
|
||||
{renderNextButton()}
|
||||
</div>
|
||||
@@ -82,12 +81,10 @@ UnitNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
onClickPrevious: PropTypes.func.isRequired,
|
||||
onClickNext: PropTypes.func.isRequired,
|
||||
isAtTop: PropTypes.bool,
|
||||
};
|
||||
|
||||
UnitNavigation.defaultProps = {
|
||||
unitId: null,
|
||||
isAtTop: false,
|
||||
};
|
||||
|
||||
export default injectIntl(UnitNavigation);
|
||||
|
||||
@@ -86,50 +86,6 @@ 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);
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import { sequenceIdsSelector } from '../../../data';
|
||||
import SidebarContext from '../../sidebar/SidebarContext';
|
||||
import NewSidebarContext from '../../new-sidebar/SidebarContext';
|
||||
import { WIDGETS } from '../../../../constants';
|
||||
|
||||
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequence = useModel('sequences', currentSequenceId);
|
||||
const courseId = useSelector(state => state.courseware.courseId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const { 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.
|
||||
@@ -26,16 +21,6 @@ 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);
|
||||
|
||||
@@ -83,28 +68,3 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
|
||||
navigationDisabledPrevSequence,
|
||||
};
|
||||
}
|
||||
|
||||
export function useIsOnMediumDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.medium.minWidth && windowSize.width < breakpoints.extraLarge.minWidth;
|
||||
}
|
||||
|
||||
export function useIsOnLargeDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.extraLarge.minWidth && windowSize.width < breakpoints.extraLarge.maxWidth;
|
||||
}
|
||||
|
||||
export function useIsOnXLDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.extraLarge.maxWidth;
|
||||
}
|
||||
|
||||
export function useIsSidebarOpen(unitId) {
|
||||
const courseId = useSelector(state => state.courseware.courseId);
|
||||
const { isNewDiscussionSidebarViewEnabled } = useModel('courseHomeMeta', courseId);
|
||||
const { currentSidebar } = useContext(isNewDiscussionSidebarViewEnabled ? NewSidebarContext : SidebarContext);
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
|
||||
return (currentSidebar === WIDGETS.NOTIFICATIONS) || (currentSidebar === 'DISCUSSIONS_NOTIFICATIONS') || (
|
||||
currentSidebar === WIDGETS.DISCUSSIONS && !!(topic?.id || topic?.enabledInContext));
|
||||
}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
|
||||
const Sidebar = () => {
|
||||
const { currentSidebar } = useContext(SidebarContext);
|
||||
|
||||
if (!currentSidebar || !SIDEBARS[currentSidebar]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
|
||||
|
||||
return (
|
||||
<SidebarToRender />
|
||||
);
|
||||
};
|
||||
const Sidebar = () => (
|
||||
<>
|
||||
{
|
||||
SIDEBAR_ORDER.map((sideBarId) => {
|
||||
const SidebarToRender = SIDEBARS[sideBarId].Sidebar;
|
||||
return <SidebarToRender key={sideBarId} />;
|
||||
})
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
import React, {
|
||||
useEffect, useState, useMemo, useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { getLocalStorage, setLocalStorage } from '@src/data/localStorage';
|
||||
import { getCoursewareOutlineSidebarSettings } from '../../data/selectors';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
|
||||
|
||||
import * as discussionsSidebar from './sidebars/discussions';
|
||||
import * as notificationsSidebar from './sidebars/notifications';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
|
||||
@@ -20,35 +16,18 @@ const SidebarProvider = ({
|
||||
children,
|
||||
}) => {
|
||||
const { verifiedMode } = useModel('courseHomeMeta', courseId);
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
const isUnitHasDiscussionTopics = topic?.id && topic?.enabledInContext;
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.extraLarge.minWidth;
|
||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.extraLarge.minWidth;
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const { alwaysOpenAuxiliarySidebar } = useSelector(getCoursewareOutlineSidebarSettings);
|
||||
const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true';
|
||||
|
||||
let initialSidebar = shouldDisplayFullScreen ? getLocalStorage(`sidebar.${courseId}`) : null;
|
||||
if (!shouldDisplayFullScreen && isInitiallySidebarOpen && alwaysOpenAuxiliarySidebar) {
|
||||
initialSidebar = isUnitHasDiscussionTopics
|
||||
? SIDEBARS[discussionsSidebar.ID].ID
|
||||
: verifiedMode && SIDEBARS[notificationsSidebar.ID].ID;
|
||||
}
|
||||
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null;
|
||||
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSidebar && currentSidebar !== initialSidebar) {
|
||||
setCurrentSidebar(initialSidebar);
|
||||
}
|
||||
}, [unitId, topic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSidebar) {
|
||||
setCurrentSidebar(initialSidebar);
|
||||
}
|
||||
}, [shouldDisplaySidebarOpen]);
|
||||
// if the user hasn't purchased the course, show the notifications sidebar
|
||||
setCurrentSidebar(verifiedMode ? SIDEBARS.NOTIFICATIONS.ID : SIDEBARS.DISCUSSIONS.ID);
|
||||
}, [unitId]);
|
||||
|
||||
const onNotificationSeen = useCallback(() => {
|
||||
setNotificationStatus('inactive');
|
||||
@@ -57,13 +36,10 @@ const SidebarProvider = ({
|
||||
|
||||
const toggleSidebar = useCallback((sidebarId) => {
|
||||
// Switch to new sidebar or hide the current sidebar
|
||||
const newSidebar = sidebarId === currentSidebar ? null : sidebarId;
|
||||
setCurrentSidebar(newSidebar);
|
||||
setLocalStorage(`sidebar.${courseId}`, newSidebar);
|
||||
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
|
||||
}, [currentSidebar]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
initialSidebar,
|
||||
toggleSidebar,
|
||||
onNotificationSeen,
|
||||
setNotificationStatus,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext } from 'react';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||
@@ -19,8 +19,8 @@ const SidebarTriggers = () => {
|
||||
const isActive = sidebarId === currentSidebar;
|
||||
return (
|
||||
<div
|
||||
className={classNames({ 'ml-1': !isMobileView, 'border-primary-700 sidebar-active': isActive })}
|
||||
style={{ borderBottom: '2px solid', borderColor: isActive ? 'inherit' : 'transparent' }}
|
||||
className={classNames({ 'mt-3': !isMobileView, 'border-primary-700': isActive })}
|
||||
style={{ borderBottom: isActive ? '2px solid' : null }}
|
||||
key={sidebarId}
|
||||
>
|
||||
<Trigger onClick={() => toggleSidebar(sidebarId)} key={sidebarId} />
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Icon, IconButton } from '@openedx/paragon';
|
||||
import { ArrowBackIos, Close } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useEventListener } from '@src/generic/hooks';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { useEventListener } from '../../../../generic/hooks';
|
||||
import messages from '../../messages';
|
||||
import SidebarContext from '../SidebarContext';
|
||||
|
||||
@@ -36,15 +36,14 @@ const SidebarBase = ({
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classNames('ml-0 border border-light-400 rounded-sm h-auto align-top zindex-0', {
|
||||
className={classNames('ml-0 ml-lg-4 border border-light-400 rounded-sm h-auto align-top', {
|
||||
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
|
||||
'align-self-start': !shouldDisplayFullScreen,
|
||||
'min-vh-100': !shouldDisplayFullScreen,
|
||||
'd-none': currentSidebar !== sidebarId,
|
||||
}, className)}
|
||||
data-testid={`sidebar-${sidebarId}`}
|
||||
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
|
||||
aria-label={ariaLabel}
|
||||
id="course-sidebar"
|
||||
>
|
||||
{shouldDisplayFullScreen ? (
|
||||
<div
|
||||
@@ -53,6 +52,7 @@ const SidebarBase = ({
|
||||
onKeyDown={() => toggleSidebar(null)}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}
|
||||
>
|
||||
<Icon src={ArrowBackIos} />
|
||||
<span className="font-weight-bold m-2 d-inline-block">
|
||||
@@ -62,12 +62,12 @@ const SidebarBase = ({
|
||||
) : null}
|
||||
{showTitleBar && (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<strong className="p-2.5 d-inline-block course-sidebar-title">{title}</strong>
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="p-2.5 d-inline-block">{title}</span>
|
||||
{shouldDisplayFullScreen
|
||||
? null
|
||||
: (
|
||||
<div className="d-inline-flex mr-2 ml-auto">
|
||||
<div className="d-inline-flex mr-2 mt-1.5 ml-auto">
|
||||
<IconButton
|
||||
src={Close}
|
||||
size="sm"
|
||||
@@ -79,6 +79,7 @@ const SidebarBase = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-1 bg-gray-100 border-top border-bottom border-light-400" />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
@@ -91,7 +92,7 @@ SidebarBase.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
ariaLabel: PropTypes.string.isRequired,
|
||||
sidebarId: PropTypes.string.isRequired,
|
||||
className: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.element.isRequired,
|
||||
showTitleBar: PropTypes.bool,
|
||||
width: PropTypes.string,
|
||||
@@ -100,6 +101,7 @@ SidebarBase.propTypes = {
|
||||
SidebarBase.defaultProps = {
|
||||
width: '31rem',
|
||||
showTitleBar: true,
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarBase);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#course-sidebar {
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "lg")) {
|
||||
overflow-y: scroll;
|
||||
padding: 0 .625rem !important;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button, useToggle, IconButton } from '@openedx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
MenuOpen as MenuOpenIcon,
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { LOADING, LOADED } from '@src/course-home/data/slice';
|
||||
import PageLoading from '@src/generic/PageLoading';
|
||||
import {
|
||||
getSequenceId,
|
||||
getCourseOutline,
|
||||
getCourseOutlineStatus,
|
||||
getCourseOutlineShouldUpdate,
|
||||
} from '../../../../data/selectors';
|
||||
import { getCourseOutlineStructure } from '../../../../data/thunks';
|
||||
import SidebarSection from './components/SidebarSection';
|
||||
import SidebarSequence from './components/SidebarSequence';
|
||||
import { ID } from './constants';
|
||||
import { useCourseOutlineSidebar } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const CourseOutlineTray = ({ intl }) => {
|
||||
const [selectedSection, setSelectedSection] = useState(null);
|
||||
const [isDisplaySequenceLevel, setDisplaySequenceLevel, setDisplaySectionLevel] = useToggle(true);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const activeSequenceId = useSelector(getSequenceId);
|
||||
const { sections = {}, sequences = {} } = useSelector(getCourseOutline);
|
||||
const courseOutlineStatus = useSelector(getCourseOutlineStatus);
|
||||
const courseOutlineShouldUpdate = useSelector(getCourseOutlineShouldUpdate);
|
||||
|
||||
const {
|
||||
courseId,
|
||||
unitId,
|
||||
isEnabledSidebar,
|
||||
currentSidebar,
|
||||
handleToggleCollapse,
|
||||
isActiveEntranceExam,
|
||||
shouldDisplayFullScreen,
|
||||
} = useCourseOutlineSidebar();
|
||||
|
||||
const {
|
||||
sectionId: activeSectionId,
|
||||
} = useModel('sequences', activeSequenceId);
|
||||
|
||||
const sectionsIds = Object.keys(sections);
|
||||
const sequenceIds = sections[selectedSection || activeSectionId]?.sequenceIds || [];
|
||||
const backButtonTitle = sections[selectedSection || activeSectionId]?.title;
|
||||
|
||||
const handleBackToSectionLevel = () => {
|
||||
setDisplaySectionLevel();
|
||||
setSelectedSection(null);
|
||||
};
|
||||
|
||||
const handleSelectSection = (id) => {
|
||||
setDisplaySequenceLevel();
|
||||
setSelectedSection(id);
|
||||
};
|
||||
|
||||
const sidebarHeading = (
|
||||
<div className="outline-sidebar-heading-wrapper sticky d-flex justify-content-between align-self-start align-items-center bg-light-200 p-2.5 pl-4">
|
||||
{isDisplaySequenceLevel && backButtonTitle ? (
|
||||
<Button
|
||||
variant="link"
|
||||
iconBefore={ChevronLeftIcon}
|
||||
className="outline-sidebar-heading p-0 mb-0 text-left text-dark-500"
|
||||
onClick={handleBackToSectionLevel}
|
||||
>
|
||||
{backButtonTitle}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="outline-sidebar-heading mb-0 h4 text-dark-500">
|
||||
{intl.formatMessage(messages.courseOutlineTitle)}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.toggleCourseOutlineTrigger)}
|
||||
className="outline-sidebar-toggle-btn flex-shrink-0 text-dark bg-light-200"
|
||||
iconAs={MenuOpenIcon}
|
||||
onClick={handleToggleCollapse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if ((isEnabledSidebar && courseOutlineStatus !== LOADED) || courseOutlineShouldUpdate) {
|
||||
dispatch(getCourseOutlineStructure(courseId));
|
||||
}
|
||||
}, [courseId, isEnabledSidebar, courseOutlineShouldUpdate]);
|
||||
|
||||
if (!isEnabledSidebar || isActiveEntranceExam || currentSidebar !== ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (courseOutlineStatus === LOADING) {
|
||||
return (
|
||||
<div className={classNames('outline-sidebar-wrapper', {
|
||||
'flex-shrink-0 mr-4 h-auto': !shouldDisplayFullScreen,
|
||||
'bg-white m-0 fixed-top w-100 vh-100': shouldDisplayFullScreen,
|
||||
})}
|
||||
>
|
||||
<section className="outline-sidebar w-100">
|
||||
{sidebarHeading}
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loading)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('outline-sidebar-wrapper', {
|
||||
'flex-shrink-0 mr-4 h-auto': !shouldDisplayFullScreen,
|
||||
'bg-white m-0 fixed-top w-100 vh-100': shouldDisplayFullScreen,
|
||||
})}
|
||||
>
|
||||
<section className="outline-sidebar w-100">
|
||||
{sidebarHeading}
|
||||
<ol id="outline-sidebar-outline" className="list-unstyled">
|
||||
{isDisplaySequenceLevel
|
||||
? sequenceIds.map((sequenceId) => (
|
||||
<SidebarSequence
|
||||
key={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
defaultOpen={sequenceId === activeSequenceId}
|
||||
activeUnitId={unitId}
|
||||
/>
|
||||
))
|
||||
: sectionsIds.map((sectionId) => (
|
||||
<SidebarSection
|
||||
key={sectionId}
|
||||
courseId={courseId}
|
||||
section={sections[sectionId]}
|
||||
handleSelectSection={handleSelectSection}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseOutlineTray.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
CourseOutlineTray.ID = ID;
|
||||
|
||||
export default injectIntl(CourseOutlineTray);
|
||||
@@ -1,103 +0,0 @@
|
||||
.outline-sidebar-wrapper {
|
||||
width: 32.125rem;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outline-sidebar {
|
||||
@media (min-width: map-get($grid-breakpoints, "xl")) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.outline-sidebar-heading-wrapper {
|
||||
border: 1px solid #d7d3d1;
|
||||
|
||||
&.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.outline-sidebar-heading {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.course-sidebar-section {
|
||||
background: $white;
|
||||
border: 1px solid #d7d3d1;
|
||||
|
||||
button {
|
||||
line-height: 1.75rem;
|
||||
|
||||
&.focus::before,
|
||||
&:focus::before {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.outline-sidebar-toggle-btn {
|
||||
font-size: 1.5rem;
|
||||
|
||||
.collapsed & {
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#outline-sidebar-outline {
|
||||
margin-top: -1px;
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, "xl")) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
|
||||
.collapsible-trigger {
|
||||
border-radius: 0;
|
||||
padding: map-get($spacers, 3\.5) map-get($spacers, 4) map-get($spacers, 3\.5) map-get($spacers, 5);
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
padding-left: map-get($spacers, 4);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $light-500;
|
||||
}
|
||||
|
||||
.collapsible-icon {
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child .pgn_collapsible {
|
||||
@extend .mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-body {
|
||||
padding: 0;
|
||||
|
||||
ol li > a {
|
||||
padding: map-get($spacers, 3\.5) map-get($spacers, 4) map-get($spacers, 3\.5) map-get($spacers, 5\.5);
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
padding-left: map-get($spacers, 4\.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background-color: $light-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { initializeTestStore } from '@src/setupTest';
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import CourseOutlineTray from './CourseOutlineTray';
|
||||
import { ID as outlineSidebarId } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
describe('<CourseOutlineTray />', () => {
|
||||
let store;
|
||||
let section = {};
|
||||
let sequence = {};
|
||||
let unit;
|
||||
let unitId;
|
||||
let courseId;
|
||||
let mockData;
|
||||
|
||||
const initTestStore = async (options) => {
|
||||
store = await initializeTestStore(options);
|
||||
const state = store.getState();
|
||||
courseId = state.courseware.courseId;
|
||||
[unitId] = Object.keys(state.models.units);
|
||||
|
||||
if (Object.keys(state.courseware.courseOutline).length) {
|
||||
const [activeSequenceId] = Object.keys(state.courseware.courseOutline.sequences);
|
||||
sequence = state.courseware.courseOutline.sequences[activeSequenceId];
|
||||
const activeSectionId = Object.keys(state.courseware.courseOutline.sections)[0];
|
||||
section = state.courseware.courseOutline.sections[activeSectionId];
|
||||
[unitId] = sequence.unitIds;
|
||||
unit = state.courseware.courseOutline.units[unitId];
|
||||
}
|
||||
|
||||
mockData = {
|
||||
courseId,
|
||||
unitId,
|
||||
currentSidebar: outlineSidebarId,
|
||||
toggleSidebar: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
function renderWithProvider(testData = {}) {
|
||||
const { container } = render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en">
|
||||
<SidebarContext.Provider value={{ ...mockData, ...testData }}>
|
||||
<MemoryRouter>
|
||||
<CourseOutlineTray />
|
||||
</MemoryRouter>
|
||||
</SidebarContext.Provider>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
it('renders correctly when course outline is loading', async () => {
|
||||
await initTestStore({ excludeFetchOutlineSidebar: true });
|
||||
renderWithProvider();
|
||||
|
||||
expect(screen.getByText(messages.loading.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.courseOutlineTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Course Outline' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('doesn\'t render when outline sidebar is disabled', async () => {
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: false } });
|
||||
renderWithProvider();
|
||||
|
||||
await expect(screen.queryByText(messages.loading.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: section.title })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: messages.toggleCourseOutlineTrigger.defaultMessage })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly when course outline is loaded', async () => {
|
||||
await initTestStore();
|
||||
renderWithProvider();
|
||||
|
||||
await expect(screen.queryByText(messages.loading.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: section.title })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.toggleCourseOutlineTrigger.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` })).toBeInTheDocument();
|
||||
expect(screen.getByText(unit.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses sidebar correctly when toggle button is clicked', async () => {
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore();
|
||||
renderWithProvider({ toggleSidebar: mockToggleSidebar });
|
||||
|
||||
const collapseBtn = screen.getByRole('button', { name: messages.toggleCourseOutlineTrigger.defaultMessage });
|
||||
const sidebarBackBtn = screen.queryByRole('button', { name: section.title });
|
||||
expect(sidebarBackBtn).toBeInTheDocument();
|
||||
expect(collapseBtn).toBeInTheDocument();
|
||||
|
||||
userEvent.click(collapseBtn);
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('navigates to section or sequence level correctly on click by back/section button', async () => {
|
||||
await initTestStore();
|
||||
renderWithProvider();
|
||||
|
||||
const sidebarBackBtn = screen.queryByRole('button', { name: section.title });
|
||||
expect(sidebarBackBtn).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` })).toBeInTheDocument();
|
||||
|
||||
userEvent.click(sidebarBackBtn);
|
||||
expect(sidebarBackBtn).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.courseOutlineTitle.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: `${section.title} , ${courseOutlineMessages.incompleteSection.defaultMessage}` }));
|
||||
expect(screen.queryByRole('button', { name: section.title })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { IconButton } from '@openedx/paragon';
|
||||
import { MenuOpen as MenuOpenIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import { useCourseOutlineSidebar } from './hooks';
|
||||
import { ID } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
const CourseOutlineTrigger = ({ intl, isMobileView }) => {
|
||||
const {
|
||||
currentSidebar,
|
||||
shouldDisplayFullScreen,
|
||||
handleToggleCollapse,
|
||||
isActiveEntranceExam,
|
||||
isEnabledSidebar,
|
||||
} = useCourseOutlineSidebar();
|
||||
|
||||
const isDisplayForDesktopView = !isMobileView && !shouldDisplayFullScreen && currentSidebar !== ID;
|
||||
const isDisplayForMobileView = isMobileView && shouldDisplayFullScreen;
|
||||
|
||||
if ((!isDisplayForDesktopView && !isDisplayForMobileView) || !isEnabledSidebar || isActiveEntranceExam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('outline-sidebar-heading-wrapper bg-light-200 collapsed align-self-start', {
|
||||
'flex-shrink-0 mr-4 p-2.5': isDisplayForDesktopView,
|
||||
'p-0': isDisplayForMobileView,
|
||||
})}
|
||||
>
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.toggleCourseOutlineTrigger)}
|
||||
className="outline-sidebar-toggle-btn flex-shrink-0 text-dark bg-light-200 rounded-0"
|
||||
iconAs={MenuOpenIcon}
|
||||
onClick={handleToggleCollapse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseOutlineTrigger.defaultProps = {
|
||||
isMobileView: false,
|
||||
};
|
||||
|
||||
CourseOutlineTrigger.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isMobileView: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseOutlineTrigger);
|
||||
@@ -1,109 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { initializeTestStore } from '@src/setupTest';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import { ID as discussionSidebarId } from '../discussions/DiscussionsTrigger';
|
||||
import CourseOutlineTrigger from './CourseOutlineTrigger';
|
||||
import { ID as outlineSidebarId } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
describe('<CourseOutlineTrigger />', () => {
|
||||
let mockData;
|
||||
let courseId;
|
||||
let unitId;
|
||||
let store;
|
||||
|
||||
const initTestStore = async (options) => {
|
||||
store = await initializeTestStore(options);
|
||||
const state = store.getState();
|
||||
courseId = state.courseware.courseId;
|
||||
[unitId] = Object.keys(state.models.units);
|
||||
|
||||
mockData = {
|
||||
courseId,
|
||||
unitId,
|
||||
currentSidebar: discussionSidebarId,
|
||||
};
|
||||
};
|
||||
|
||||
function renderWithProvider(testData = {}, props = {}) {
|
||||
const { container } = render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en">
|
||||
<SidebarContext.Provider value={{ ...mockData, ...testData }}>
|
||||
<CourseOutlineTrigger {...props} />
|
||||
</SidebarContext.Provider>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
it('renders correctly for desktop when sidebar is enabled', async () => {
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({ toggleSidebar: mockToggleSidebar }, { isMobileView: false });
|
||||
|
||||
const toggleButton = await screen.getByRole('button', {
|
||||
name: messages.toggleCourseOutlineTrigger.defaultMessage,
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalled();
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(outlineSidebarId);
|
||||
});
|
||||
|
||||
it('renders correctly for mobile when sidebar is enabled', async () => {
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({
|
||||
toggleSidebar: mockToggleSidebar,
|
||||
shouldDisplayFullScreen: true,
|
||||
}, { isMobileView: true });
|
||||
|
||||
const toggleButton = await screen.getByRole('button', {
|
||||
name: messages.toggleCourseOutlineTrigger.defaultMessage,
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalled();
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(outlineSidebarId);
|
||||
});
|
||||
|
||||
it('changes current sidebar value on click', async () => {
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({
|
||||
toggleSidebar: mockToggleSidebar,
|
||||
shouldDisplayFullScreen: true,
|
||||
currentSidebar: outlineSidebarId,
|
||||
}, { isMobileView: true });
|
||||
|
||||
const toggleButton = await screen.getByRole('button', {
|
||||
name: messages.toggleCourseOutlineTrigger.defaultMessage,
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalledTimes(1);
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('does not render when isEnabled is false', async () => {
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: false } });
|
||||
renderWithProvider({}, { isMobileView: false });
|
||||
|
||||
const toggleButton = await screen.queryByRole('button', {
|
||||
name: messages.toggleCourseOutlineTrigger.defaultMessage,
|
||||
});
|
||||
expect(toggleButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
LmsCompletionSolid as LmsCompletionSolidIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { DashedCircleIcon } from '../icons';
|
||||
|
||||
const CompletionIcon = ({ completionStat: { completed = 0, total = 0 } }) => {
|
||||
const percentage = total !== 0 ? Math.min((completed / total) * 100, 100) : 0;
|
||||
const remainder = 100 - percentage;
|
||||
|
||||
switch (true) {
|
||||
case !completed:
|
||||
return <LmsCompletionSolidIcon className="text-gray-300" data-testid="completion-solid-icon" />;
|
||||
case completed === total:
|
||||
return <CheckCircleIcon className="text-success" data-testid="check-circle-icon" />;
|
||||
default:
|
||||
return <DashedCircleIcon percentage={percentage} remainder={remainder} data-testid="dashed-circle-icon" />;
|
||||
}
|
||||
};
|
||||
|
||||
CompletionIcon.propTypes = {
|
||||
completionStat: PropTypes.shape({
|
||||
completed: PropTypes.number,
|
||||
total: PropTypes.number,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CompletionIcon;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import CompletionIcon from './CompletionIcon';
|
||||
|
||||
describe('CompletionIcon', () => {
|
||||
it('renders check circle icon when completion is equal to total', () => {
|
||||
const completionStat = { completed: 5, total: 5 };
|
||||
render(<CompletionIcon completionStat={completionStat} />);
|
||||
expect(screen.getByTestId('check-circle-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dashed circle icon when completion is between 0 and total', () => {
|
||||
const completionStat = { completed: 2, total: 5 };
|
||||
render(<CompletionIcon completionStat={completionStat} />);
|
||||
expect(screen.getByTestId('dashed-circle-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders completion solid icon when completion is 0', () => {
|
||||
const completionStat = { completed: 0, total: 5 };
|
||||
render(<CompletionIcon completionStat={completionStat} />);
|
||||
expect(screen.getByTestId('completion-solid-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@openedx/paragon';
|
||||
import { ChevronRight as ChevronRightIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import { getSequenceId } from '@src/courseware/data/selectors';
|
||||
import CompletionIcon from './CompletionIcon';
|
||||
|
||||
const SidebarSection = ({ intl, section, handleSelectSection }) => {
|
||||
const {
|
||||
id,
|
||||
complete,
|
||||
title,
|
||||
sequenceIds,
|
||||
completionStat,
|
||||
} = section;
|
||||
|
||||
const activeSequenceId = useSelector(getSequenceId);
|
||||
const isActiveSection = sequenceIds.includes(activeSequenceId);
|
||||
|
||||
const sectionTitle = (
|
||||
<>
|
||||
<div className="col-auto p-0">
|
||||
<CompletionIcon completionStat={completionStat} />
|
||||
</div>
|
||||
<div className="col-10 ml-3 p-0 flex-grow-1 text-dark-500 text-left text-break">
|
||||
{title}
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete
|
||||
? courseOutlineMessages.completedSection
|
||||
: courseOutlineMessages.incompleteSection)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="mb-2 course-sidebar-section">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className={classNames(
|
||||
'd-flex align-items-center w-100 px-4 py-3.5 rounded-0 justify-content-start',
|
||||
{ 'bg-info-100': isActiveSection },
|
||||
)}
|
||||
onClick={() => handleSelectSection(id)}
|
||||
>
|
||||
{sectionTitle}
|
||||
<Icon src={ChevronRightIcon} />
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
SidebarSection.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
section: PropTypes.shape({
|
||||
complete: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
sequenceIds: PropTypes.arrayOf(PropTypes.string),
|
||||
completionStat: PropTypes.shape({
|
||||
completed: PropTypes.number,
|
||||
total: PropTypes.number,
|
||||
}),
|
||||
}).isRequired,
|
||||
handleSelectSection: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarSection);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user