Compare commits
101 Commits
abdullahwa
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4edaa1a2a4 | ||
|
|
6f1159617e | ||
|
|
8cc6b8cdde | ||
|
|
048488fb25 | ||
|
|
2a58ad2477 | ||
|
|
798c51b4e7 | ||
|
|
9a83d67d78 | ||
|
|
356b183c5c | ||
|
|
d64a4e448b | ||
|
|
860b3f9952 | ||
|
|
4418c5422f | ||
|
|
372c9de1db | ||
|
|
65adaf18d4 | ||
|
|
ed77465282 | ||
|
|
f5f6747ecb | ||
|
|
fbe16483ac | ||
|
|
e4a0105042 | ||
|
|
1d19ae0e7b | ||
|
|
b9d11982e3 | ||
|
|
73590f1ccd | ||
|
|
f8d35bf45d | ||
|
|
2038bad822 | ||
|
|
6c11947397 | ||
|
|
26565cd89c | ||
|
|
3a203e8351 | ||
|
|
500e4abcb9 | ||
|
|
d78851bb5b | ||
|
|
8c9a43d02b | ||
|
|
13c7c1de89 | ||
|
|
ba44b28cec | ||
|
|
79affe0629 | ||
|
|
d0ec7e3fb2 | ||
|
|
3f8b8077a9 | ||
|
|
290f17d76d | ||
|
|
64a1149550 | ||
|
|
e907ade40a | ||
|
|
68a7bf5527 | ||
|
|
db75ea28e4 | ||
|
|
ba4bdfe6af | ||
|
|
b63508db97 | ||
|
|
82b27e59cc | ||
|
|
ec8b5c5d6e | ||
|
|
dc1e9cd2e8 | ||
|
|
a681333a08 | ||
|
|
7cbbc720d1 | ||
|
|
863a838e6e | ||
|
|
5b046e828a | ||
|
|
a8f72c5e75 | ||
|
|
bb6c678904 | ||
|
|
71c2a31531 | ||
|
|
99a44dda37 | ||
|
|
5ae86465cc | ||
|
|
6e9c105eb9 | ||
|
|
26199fa954 | ||
|
|
3a542766d7 | ||
|
|
bbe03dc46f | ||
|
|
167d51b596 | ||
|
|
7efe8f5cc3 | ||
|
|
263fe6d1a2 | ||
|
|
76f98d5bb2 | ||
|
|
e0386fe40b | ||
|
|
7d99677acd | ||
|
|
29bc2d9e17 | ||
|
|
27f3e79508 | ||
|
|
ed74bee760 | ||
|
|
c7a81fe07a | ||
|
|
d880aac569 | ||
|
|
072d608c64 | ||
|
|
9437142bc8 | ||
|
|
58c8ec5777 | ||
|
|
07357b9f10 | ||
|
|
1264b4245c | ||
|
|
e3ecee18e3 | ||
|
|
c3d96622e8 | ||
|
|
e577efbd27 | ||
|
|
df361236d0 | ||
|
|
e656f5445c | ||
|
|
f124c0d491 | ||
|
|
cc041ba348 | ||
|
|
257c9dcd7f | ||
|
|
1857b86c7e | ||
|
|
1c3610e9af | ||
|
|
796bbef10b | ||
|
|
799e57f970 | ||
|
|
cf3a91dde0 | ||
|
|
72381a783b | ||
|
|
75f56ea4bd | ||
|
|
a418ba6adb | ||
|
|
2da930f819 | ||
|
|
a2c38112fb | ||
|
|
36535d188d | ||
|
|
79f49032e3 | ||
|
|
9b3b123e45 | ||
|
|
98436b4605 | ||
|
|
7652fa46d1 | ||
|
|
2347ce88cd | ||
|
|
78e5c57bd3 | ||
|
|
108636761c | ||
|
|
5f56828bda | ||
|
|
23e522e893 | ||
|
|
9423a889ba |
3
.env
3
.env
@@ -4,7 +4,7 @@
|
||||
NODE_ENV='production'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
AI_TRANSLATIONS_URL=''
|
||||
APP_ID='learning'
|
||||
BASE_URL=''
|
||||
CONTACT_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
@@ -14,7 +14,6 @@ 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'
|
||||
AI_TRANSLATIONS_URL='http://localhost:18760'
|
||||
APP_ID='learning'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
@@ -14,7 +14,6 @@ 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'
|
||||
AI_TRANSLATIONS_URL='http://localhost:18760'
|
||||
APP_ID='learning'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
@@ -14,7 +14,6 @@ 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,3 +3,5 @@ dist/
|
||||
packages/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
env.config.jsx
|
||||
example.env.config.jsx
|
||||
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
32
.github/workflows/validate.yml
vendored
32
.github/workflows/validate.yml
vendored
@@ -9,15 +9,35 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [18, 20]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report-${{ matrix.node }}
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- run: make validate.ci
|
||||
name: code-coverage-report-20
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
2
Makefile
2
Makefile
@@ -55,8 +55,10 @@ validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run types
|
||||
npm run test
|
||||
npm run build
|
||||
npm run bundlewatch
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
|
||||
120
README.rst
120
README.rst
@@ -1,77 +1,110 @@
|
||||
#####################
|
||||
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
|
||||
=============
|
||||
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
`Tutor`_ is currently recommended as a development environment for the Learning
|
||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
||||
make some changes in order to run it in development mode. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
||||
guide below.
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
|
||||
|
||||
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
Cloning and Setup
|
||||
=================
|
||||
|
||||
Cloning and Startup
|
||||
===================
|
||||
1. Clone your new repo:
|
||||
|
||||
.. code-block::
|
||||
.. code-block:: bash
|
||||
|
||||
1. Clone your new repo:
|
||||
git clone https://github.com/openedx/frontend-app-learning.git
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-learning.git``
|
||||
2. Use node v20.x.
|
||||
|
||||
2. Use node v18.x.
|
||||
The current version of the micro-frontend build scripts supports node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an ``.nvmrc`` file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
||||
|
||||
3. Install npm dependencies:
|
||||
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:
|
||||
|
||||
``cd frontend-app-learning && npm ci``
|
||||
.. code-block:: bash
|
||||
|
||||
4. Start the dev server:
|
||||
tutor mounts add /path/to/frontend-app-learning
|
||||
|
||||
``npm start``
|
||||
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
|
||||
|
||||
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::
|
||||
file (which is git-ignored) that defines where to find your local modules, for instance:
|
||||
|
||||
.. code-block:: js
|
||||
|
||||
module.exports = {
|
||||
/*
|
||||
@@ -100,8 +133,14 @@ 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,
|
||||
@@ -127,7 +166,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
|
||||
|
||||
@@ -140,7 +179,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
|
||||
@@ -156,13 +195,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/edXOnline
|
||||
Example: https://twitter.com/openedx
|
||||
|
||||
Getting Help
|
||||
===========
|
||||
============
|
||||
|
||||
If you're having trouble, we have discussion forums at
|
||||
https://discuss.openedx.org where you can connect with others in the community.
|
||||
If you're having trouble, we have `discussion forums`_
|
||||
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
|
||||
@@ -180,17 +219,18 @@ 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 have a discussion about your new feature idea with the maintainers prior to
|
||||
to discuss your new feature idea with the maintainers before
|
||||
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:2u-aurora
|
||||
owner: group:committers-frontend-app-learning
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import UnitTranslationPlugin from '@plugins/UnitTranslationPlugin';
|
||||
import UnitTranslationPlugin from '@edx/unit-translation-selector-plugin';
|
||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
// Load environment variables from .env file
|
||||
|
||||
@@ -9,13 +9,12 @@ const config = createConfig('jest', {
|
||||
'src/i18n',
|
||||
'src/.*\\.exp\\..*',
|
||||
],
|
||||
// see https://github.com/axios/axios/issues/5026
|
||||
moduleNameMapper: {
|
||||
"^axios$": "axios/dist/axios.js",
|
||||
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
|
||||
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
|
||||
'@src/(.*)': '<rootDir>/src/$1',
|
||||
'@plugins/(.*)': '<rootDir>/plugins/$1',
|
||||
// Explicit mapping to ensure Jest resolves the module correctly
|
||||
'@edx/frontend-lib-special-exams': '<rootDir>/node_modules/@edx/frontend-lib-special-exams',
|
||||
},
|
||||
testTimeout: 30000,
|
||||
globalSetup: "./global-setup.js",
|
||||
@@ -27,7 +26,7 @@ const config = createConfig('jest', {
|
||||
|
||||
config.reporters = [...(config.reporters || []), ["jest-console-group-reporter", {
|
||||
// change this setting if need to see less details for each test
|
||||
// reportType: "summary" | "details",
|
||||
// reportType: "summary" | "details",
|
||||
// enable: true | false,
|
||||
afterEachTest: {
|
||||
enable: true,
|
||||
|
||||
11920
package-lock.json
generated
11920
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
80
package.json
80
package.json
@@ -11,13 +11,17 @@
|
||||
],
|
||||
"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 .",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"prepare": "husky install",
|
||||
"postinstall": "patch-package",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
"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",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
@@ -30,28 +34,33 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@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/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-component-header": "^5.8.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.2.4",
|
||||
"@edx/frontend-lib-special-exams": "^3.1.3",
|
||||
"@edx/frontend-platform": "^8.0.0",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@edx/react-unit-test-utils": "^2.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@edx/react-unit-test-utils": "3.0.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@openedx/frontend-plugin-framework": "^1.0.2",
|
||||
"@openedx/paragon": "^22.1.1",
|
||||
"@openedx/frontend-build": "14.1.2",
|
||||
"@openedx/frontend-plugin-framework": "^1.2.1",
|
||||
"@openedx/frontend-slot-footer": "^1.0.2",
|
||||
"@openedx/paragon": "^22.3.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "5.3.0",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "2.5.1",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"husky": "7.0.4",
|
||||
"joi": "^17.11.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "17.0.2",
|
||||
@@ -62,33 +71,34 @@
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.8",
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.5"
|
||||
"sass": "^1.79.3",
|
||||
"sass-loader": "^16.0.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"truncate-html": "1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@openedx/frontend-build": "13.0.30",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@pact-foundation/pact": "^13.0.0",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"es-check": "6.2.1",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"husky": "7.0.4",
|
||||
"jest": "^26.6.3",
|
||||
"jest-console-group-reporter": "^1.0.1",
|
||||
"axios-mock-adapter": "2.0.0",
|
||||
"bundlewatch": "^0.4.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.9",
|
||||
"jest": "^29.7.0",
|
||||
"jest-console-group-reporter": "^1.1.1",
|
||||
"jest-when": "^3.6.0",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"rosie": "2.1.1",
|
||||
"sass": "^1.72.0",
|
||||
"sass-loader": "^14.1.1",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"style-loader": "^3.3.4"
|
||||
"rosie": "2.1.1"
|
||||
},
|
||||
"bundlewatch": {
|
||||
"files": [
|
||||
{
|
||||
"path": "dist/*.js",
|
||||
"maxSize": "1300kB"
|
||||
}
|
||||
],
|
||||
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
|
||||
}
|
||||
}
|
||||
|
||||
36
patches/@openedx+frontend-build+13.0.30.patch
Normal file
36
patches/@openedx+frontend-build+13.0.30.patch
Normal file
@@ -0,0 +1,36 @@
|
||||
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'),
|
||||
@@ -1,17 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,15 +0,0 @@
|
||||
// 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"
|
||||
/>
|
||||
`;
|
||||
@@ -1,90 +0,0 @@
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
// 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>
|
||||
`;
|
||||
@@ -1,116 +0,0 @@
|
||||
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;
|
||||
@@ -1,4 +0,0 @@
|
||||
.action-row-divider {
|
||||
font-size: 31px;
|
||||
font-weight: 100;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
@@ -1,163 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
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;
|
||||
@@ -1,62 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
@@ -1,7 +0,0 @@
|
||||
.listbox-container {
|
||||
max-height: 400px;
|
||||
|
||||
:last-child {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
// 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>
|
||||
`;
|
||||
@@ -1,50 +0,0 @@
|
||||
// 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>
|
||||
`;
|
||||
@@ -1,100 +0,0 @@
|
||||
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;
|
||||
@@ -1,63 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
@@ -1,63 +0,0 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
@@ -1,49 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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;
|
||||
@@ -1,95 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -16,15 +16,16 @@ export const DECODE_ROUTES = {
|
||||
],
|
||||
REDIRECT_HOME: 'home/:courseId',
|
||||
REDIRECT_SURVEY: 'survey/:courseId',
|
||||
};
|
||||
} as const satisfies Readonly<{ [k: string]: string | readonly string[] }>;
|
||||
|
||||
export const ROUTES = {
|
||||
UNSUBSCRIBE: '/goal-unsubscribe/:token',
|
||||
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch',
|
||||
REDIRECT: '/redirect/*',
|
||||
DASHBOARD: 'dashboard',
|
||||
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
|
||||
CONSENT: 'consent',
|
||||
};
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const REDIRECT_MODES = {
|
||||
DASHBOARD_REDIRECT: 'dashboard-redirect',
|
||||
@@ -32,4 +33,26 @@ export const REDIRECT_MODES = {
|
||||
CONSENT_REDIRECT: 'consent-redirect',
|
||||
HOME_REDIRECT: 'home-redirect',
|
||||
SURVEY_REDIRECT: 'survey-redirect',
|
||||
};
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const VERIFIED_MODES = [
|
||||
'professional',
|
||||
'verified',
|
||||
'no-id-professional',
|
||||
'credit',
|
||||
'masters',
|
||||
'executive-education',
|
||||
'paid-executive-education',
|
||||
'paid-bootcamp',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export const WIDGETS = {
|
||||
DISCUSSIONS: 'DISCUSSIONS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
export type StatusValue = typeof LOADING | typeof LOADED | typeof FAILED | typeof DENIED;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@openedx/paragon';
|
||||
import { Search } from '@openedx/paragon/icons';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { ManageSearch } from '@openedx/paragon/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import messages from './messages';
|
||||
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
|
||||
@@ -25,16 +25,17 @@ const CoursewareSearchToggle = ({
|
||||
if (!enabled) { return null; }
|
||||
|
||||
return (
|
||||
<div className="courseware-searc-toggle">
|
||||
<div className="courseware-search-toggle">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
className="p-1 mt-2 mr-2 rounded-lg"
|
||||
className="p-1 mt-2 mr-2"
|
||||
aria-label={intl.formatMessage(messages.searchOpenAction)}
|
||||
onClick={handleSearchOpenClick}
|
||||
data-testid="courseware-search-open-button"
|
||||
iconAfter={ManageSearch}
|
||||
>
|
||||
<Icon src={Search} />
|
||||
{intl.formatMessage(messages.contentSearchButton)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
|
||||
Object {
|
||||
"filters": Array [
|
||||
Object {
|
||||
{
|
||||
"filters": [
|
||||
{
|
||||
"count": 7,
|
||||
"key": "capa",
|
||||
"label": "CAPA",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"count": 2,
|
||||
"key": "sequence",
|
||||
"label": "Sequence",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"count": 9,
|
||||
"key": "text",
|
||||
"label": "Text",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"count": 1,
|
||||
"key": "unknown",
|
||||
"label": "Unknown",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"count": 2,
|
||||
"key": "video",
|
||||
"label": "Video",
|
||||
@@ -31,11 +31,11 @@ Object {
|
||||
],
|
||||
"maxScore": 3.4545178,
|
||||
"ms": 5,
|
||||
"results": Array [
|
||||
Object {
|
||||
"results": [
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
],
|
||||
@@ -44,10 +44,10 @@ Object {
|
||||
"type": "sequence",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course",
|
||||
@@ -57,10 +57,10 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course",
|
||||
@@ -70,10 +70,10 @@ Object {
|
||||
"type": "sequence",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Text input",
|
||||
@@ -83,10 +83,10 @@ Object {
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Pointing on a Picture",
|
||||
@@ -96,10 +96,10 @@ Object {
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Getting Answers",
|
||||
@@ -109,10 +109,10 @@ Object {
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences",
|
||||
@@ -122,10 +122,10 @@ Object {
|
||||
"type": "video",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Multiple Choice Questions",
|
||||
@@ -135,10 +135,10 @@ Object {
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Numerical Input",
|
||||
@@ -148,10 +148,10 @@ Object {
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Video Presentation Styles",
|
||||
@@ -161,10 +161,10 @@ Object {
|
||||
"type": "video",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 2: Get Interactive",
|
||||
"Homework - Labs and Demos",
|
||||
"Code Grader",
|
||||
@@ -174,10 +174,10 @@ Object {
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Interactive Questions",
|
||||
@@ -187,10 +187,10 @@ Object {
|
||||
"type": "capa",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences",
|
||||
@@ -200,10 +200,10 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Discussion Forums",
|
||||
@@ -213,10 +213,10 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Overall Grade Performance",
|
||||
@@ -226,10 +226,10 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
@@ -239,10 +239,10 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
"Homework - Find Your Study Buddy",
|
||||
@@ -252,10 +252,10 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Be Social",
|
||||
@@ -265,10 +265,10 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"EdX Exams",
|
||||
@@ -278,10 +278,10 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
"location": Array [
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"When Are Your Exams? ",
|
||||
@@ -291,7 +291,7 @@ Object {
|
||||
"type": "text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"contentHits": 0,
|
||||
"id": "random-element-id",
|
||||
"location": null,
|
||||
|
||||
@@ -2,79 +2,84 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchOpenAction: {
|
||||
id: 'learn.coursewareSerch.openAction',
|
||||
id: 'learn.coursewareSearch.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.coursewareSerch.submitLabel',
|
||||
id: 'learn.coursewareSearch.submitLabel',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Button label that will submit Courseware Search.',
|
||||
},
|
||||
searchClearAction: {
|
||||
id: 'learn.coursewareSerch.clearAction',
|
||||
id: 'learn.coursewareSearch.clearAction',
|
||||
defaultMessage: 'Clear search',
|
||||
description: 'Button label that will the current Courseware Search input.',
|
||||
},
|
||||
searchCloseAction: {
|
||||
id: 'learn.coursewareSerch.closeAction',
|
||||
id: 'learn.coursewareSearch.closeAction',
|
||||
defaultMessage: 'Close the search form',
|
||||
description: 'Aria-label for a button that will close Courseware Search.',
|
||||
},
|
||||
searchModuleTitle: {
|
||||
id: 'learn.coursewareSerch.searchModuleTitle',
|
||||
id: 'learn.coursewareSearch.searchModuleTitle',
|
||||
defaultMessage: 'Search this course',
|
||||
description: 'Title for the Courseware Search module.',
|
||||
},
|
||||
searchBarPlaceholderText: {
|
||||
id: 'learn.coursewareSerch.searchBarPlaceholderText',
|
||||
id: 'learn.coursewareSearch.searchBarPlaceholderText',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Placeholder text for the Courseware Search input control',
|
||||
},
|
||||
loading: {
|
||||
id: 'learn.coursewareSerch.loading',
|
||||
id: 'learn.coursewareSearch.loading',
|
||||
defaultMessage: 'Searching...',
|
||||
description: 'Screen reader text to use on the spinner while the search is performing.',
|
||||
},
|
||||
searchResultsNone: {
|
||||
id: 'learn.coursewareSerch.searchResultsNone',
|
||||
id: 'learn.coursewareSearch.searchResultsNone',
|
||||
defaultMessage: 'No results found.',
|
||||
description: 'Text to show when the Courseware Search found no results matching the criteria.',
|
||||
},
|
||||
searchResultsLabel: {
|
||||
id: 'learn.coursewareSerch.searchResultsLabel',
|
||||
id: 'learn.coursewareSearch.searchResultsLabel',
|
||||
defaultMessage: 'Results for "{keyword}":',
|
||||
description: 'Text to show above the search results response list.',
|
||||
},
|
||||
searchResultsError: {
|
||||
id: 'learn.coursewareSerch.searchResultsError',
|
||||
id: 'learn.coursewareSearch.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.coursewareSerch.filter:all',
|
||||
id: 'learn.coursewareSearch.filter:all',
|
||||
defaultMessage: 'All content',
|
||||
description: 'Label for the search results filter that shows all content (no filter).',
|
||||
},
|
||||
'filter:text': {
|
||||
id: 'learn.coursewareSerch.filter:text',
|
||||
id: 'learn.coursewareSearch.filter:text',
|
||||
defaultMessage: 'Text',
|
||||
description: 'Label for the search results filter that shows results with text content.',
|
||||
},
|
||||
'filter:video': {
|
||||
id: 'learn.coursewareSerch.filter:video',
|
||||
id: 'learn.coursewareSearch.filter:video',
|
||||
defaultMessage: 'Video',
|
||||
description: 'Label for the search results filter that shows results with video content.',
|
||||
},
|
||||
'filter:sequence': {
|
||||
id: 'learn.coursewareSerch.filter:sequence',
|
||||
id: 'learn.coursewareSearch.filter:sequence',
|
||||
defaultMessage: 'Section',
|
||||
description: 'Label for the search results filter that shows results with section content.',
|
||||
},
|
||||
'filter:other': {
|
||||
id: 'learn.coursewareSerch.filter:other',
|
||||
id: 'learn.coursewareSearch.filter:other',
|
||||
defaultMessage: 'Other',
|
||||
description: 'Label for the search results filter that shows results with other content.',
|
||||
},
|
||||
@@ -6,6 +6,7 @@ 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`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
{
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -12,9 +12,13 @@ Object {
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseware": {
|
||||
"courseId": null,
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -22,12 +26,12 @@ Object {
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"courseAccess": {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -38,39 +42,40 @@ Object {
|
||||
"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": Array [
|
||||
Object {
|
||||
"tabs": [
|
||||
{
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
@@ -79,7 +84,7 @@ Object {
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"verifiedMode": {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -89,10 +94,10 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"dates": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"courseDateBlocks": [
|
||||
{
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
"dateType": "course-start-date",
|
||||
"description": "",
|
||||
@@ -101,7 +106,7 @@ Object {
|
||||
"link": "",
|
||||
"title": "Course Starts",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-04T02:59:40.942669Z",
|
||||
@@ -111,7 +116,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Completed",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-05T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -120,7 +125,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "Multi Badges Past Due",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -130,7 +135,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-27T02:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -140,7 +145,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Past Due 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
@@ -151,7 +156,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2020-05-28T08:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -161,7 +166,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Completed/Due 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
@@ -172,7 +177,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"complete": true,
|
||||
"date": "2020-05-29T08:59:40.942669Z",
|
||||
@@ -183,7 +188,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Completed 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"date": "2020-06-16T17:59:40.942669Z",
|
||||
"dateType": "verified-upgrade-deadline",
|
||||
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
|
||||
@@ -192,7 +197,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Upgrade to Verified Certificate",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -202,7 +207,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -212,7 +217,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Verified 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-17T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -222,7 +227,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "ORA Verified 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -232,7 +237,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-18T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -242,7 +247,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "Both Verified 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -250,7 +255,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "One Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-19T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -260,7 +265,7 @@ Object {
|
||||
"link": "https://example.com/",
|
||||
"title": "One Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -269,7 +274,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 1",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"date": "2030-08-20T05:59:40.942669Z",
|
||||
"dateType": "assignment-due-date",
|
||||
@@ -278,7 +283,7 @@ Object {
|
||||
"learnerHasAccess": true,
|
||||
"title": "Both Unreleased 2",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"date": "2030-08-23T00:00:00Z",
|
||||
"dateType": "course-end-date",
|
||||
"description": "",
|
||||
@@ -287,7 +292,7 @@ Object {
|
||||
"link": "",
|
||||
"title": "Course Ends",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"date": "2030-09-01T00:00:00Z",
|
||||
"dateType": "verification-deadline-date",
|
||||
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
|
||||
@@ -297,7 +302,7 @@ Object {
|
||||
"title": "Verification Deadline",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"datesBannerInfo": {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
@@ -309,16 +314,16 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"specialExams": {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -346,27 +351,27 @@ Object {
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"prerequisite_status": {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"examAccessToken": {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"instructions": [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
"rules": {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -377,7 +382,7 @@ Object {
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"tours": {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
@@ -388,8 +393,8 @@ Object {
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
{
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -399,9 +404,13 @@ Object {
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseware": {
|
||||
"courseId": null,
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -409,12 +418,12 @@ Object {
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"courseAccess": {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -425,39 +434,40 @@ Object {
|
||||
"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": Array [
|
||||
Object {
|
||||
"tabs": [
|
||||
{
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
@@ -466,7 +476,7 @@ Object {
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"verifiedMode": {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -476,41 +486,41 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"outline": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"accessExpiration": null,
|
||||
"canShowUpgradeSock": false,
|
||||
"certData": Object {
|
||||
"certData": {
|
||||
"certStatus": null,
|
||||
"certWebViewUrl": null,
|
||||
"certificateAvailableDate": null,
|
||||
},
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"courseBlocks": {
|
||||
"courses": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": {
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionIds": Array [
|
||||
"sectionIds": [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
|
||||
},
|
||||
},
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"sections": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"hideFromTOC": undefined,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"resumeBlock": false,
|
||||
"sequenceIds": Array [
|
||||
"sequenceIds": [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
],
|
||||
"title": "Title of Section",
|
||||
},
|
||||
},
|
||||
"sequences": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
|
||||
"sequences": {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": {
|
||||
"complete": false,
|
||||
"description": null,
|
||||
"due": null,
|
||||
@@ -526,30 +536,30 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"courseGoals": Object {
|
||||
"courseGoals": {
|
||||
"daysPerWeek": null,
|
||||
"goalOptions": Array [],
|
||||
"goalOptions": [],
|
||||
"selectedGoal": null,
|
||||
"subscribedToReminders": null,
|
||||
"weeklyLearningGoalEnabled": false,
|
||||
},
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
"courseTools": [
|
||||
{
|
||||
"analyticsId": "edx.bookmarks",
|
||||
"title": "Bookmarks",
|
||||
"url": "https://example.com/bookmarks",
|
||||
},
|
||||
],
|
||||
"datesBannerInfo": Object {
|
||||
"datesBannerInfo": {
|
||||
"contentTypeGatingEnabled": false,
|
||||
"missedDeadlines": false,
|
||||
"missedGatedContent": false,
|
||||
},
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
"datesWidget": {
|
||||
"courseDateBlocks": [],
|
||||
},
|
||||
"enableProctoredExams": undefined,
|
||||
"enrollAlert": Object {
|
||||
"enrollAlert": {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
},
|
||||
@@ -559,13 +569,13 @@ Object {
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"offer": null,
|
||||
"resumeCourse": Object {
|
||||
"resumeCourse": {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"timeOffsetMillis": 0,
|
||||
"userHasPassingGrade": undefined,
|
||||
"verifiedMode": Object {
|
||||
"verifiedMode": {
|
||||
"accessExpirationDate": "2050-01-01T12:00:00",
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -577,16 +587,16 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"specialExams": {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -614,27 +624,27 @@ Object {
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"prerequisite_status": {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"examAccessToken": {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"instructions": [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
"rules": {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -645,7 +655,7 @@ Object {
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"tours": {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
@@ -656,8 +666,8 @@ Object {
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
{
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
@@ -667,9 +677,13 @@ Object {
|
||||
"toastBodyText": null,
|
||||
"toastHeader": "",
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseware": {
|
||||
"courseId": null,
|
||||
"courseOutline": {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -677,12 +691,12 @@ Object {
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"models": {
|
||||
"courseHomeMeta": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"courseAccess": {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
"errorCode": null,
|
||||
@@ -693,39 +707,40 @@ Object {
|
||||
"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": Array [
|
||||
Object {
|
||||
"tabs": [
|
||||
{
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
@@ -734,7 +749,7 @@ Object {
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"verifiedMode": {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
@@ -744,16 +759,16 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"progress": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"accessExpiration": null,
|
||||
"certificateData": Object {},
|
||||
"completionSummary": Object {
|
||||
"certificateData": {},
|
||||
"completionSummary": {
|
||||
"completeCount": 1,
|
||||
"incompleteCount": 1,
|
||||
"lockedCount": 0,
|
||||
},
|
||||
"courseGrade": Object {
|
||||
"courseGrade": {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
@@ -764,10 +779,10 @@ Object {
|
||||
"enrollmentMode": "audit",
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
"gradesFeatureIsPartiallyLocked": false,
|
||||
"gradingPolicy": Object {
|
||||
"assignmentPolicies": Array [
|
||||
Object {
|
||||
"averageGrade": "1.00",
|
||||
"gradingPolicy": {
|
||||
"assignmentPolicies": [
|
||||
{
|
||||
"averageGrade": "1.0000",
|
||||
"numDroppable": 1,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
@@ -775,17 +790,17 @@ Object {
|
||||
"weightedGrade": 1,
|
||||
},
|
||||
],
|
||||
"gradeRange": Object {
|
||||
"gradeRange": {
|
||||
"pass": 0.75,
|
||||
},
|
||||
},
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"sectionScores": Array [
|
||||
Object {
|
||||
"sectionScores": [
|
||||
{
|
||||
"displayName": "First section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"subsections": [
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
|
||||
"displayName": "First subsection",
|
||||
@@ -794,16 +809,16 @@ Object {
|
||||
"numPointsEarned": 0,
|
||||
"numPointsPossible": 3,
|
||||
"percentGraded": 0,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"problemScores": [
|
||||
{
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"earned": 0,
|
||||
"possible": 1,
|
||||
},
|
||||
@@ -814,18 +829,18 @@ Object {
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
{
|
||||
"displayName": "Second section",
|
||||
"subsections": Array [
|
||||
Object {
|
||||
"subsections": [
|
||||
{
|
||||
"assignmentType": "Homework",
|
||||
"displayName": "Second subsection",
|
||||
"hasGradedAssignment": true,
|
||||
"numPointsEarned": 1,
|
||||
"numPointsPossible": 1,
|
||||
"percentGraded": 1,
|
||||
"problemScores": Array [
|
||||
Object {
|
||||
"problemScores": [
|
||||
{
|
||||
"earned": 1,
|
||||
"possible": 1,
|
||||
},
|
||||
@@ -839,7 +854,7 @@ Object {
|
||||
],
|
||||
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
|
||||
"userHasPassingGrade": false,
|
||||
"verificationData": Object {
|
||||
"verificationData": {
|
||||
"link": null,
|
||||
"status": "none",
|
||||
"statusDate": null,
|
||||
@@ -848,16 +863,16 @@ Object {
|
||||
},
|
||||
},
|
||||
},
|
||||
"plugins": Object {},
|
||||
"recommendations": Object {
|
||||
"plugins": {},
|
||||
"recommendations": {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"specialExams": Object {
|
||||
"specialExams": {
|
||||
"activeAttempt": null,
|
||||
"allowProctoringOptOut": false,
|
||||
"apiErrorMsg": "",
|
||||
"exam": Object {
|
||||
"attempt": Object {
|
||||
"exam": {
|
||||
"attempt": {
|
||||
"attempt_code": "",
|
||||
"attempt_id": null,
|
||||
"attempt_status": "",
|
||||
@@ -885,27 +900,27 @@ Object {
|
||||
"is_active": true,
|
||||
"is_practice_exam": false,
|
||||
"is_proctored": false,
|
||||
"prerequisite_status": Object {
|
||||
"prerequisite_status": {
|
||||
"are_prerequisites_satisifed": true,
|
||||
"declined_prerequisites": Array [],
|
||||
"failed_prerequisites": Array [],
|
||||
"pending_prerequisites": Array [],
|
||||
"satisfied_prerequisites": Array [],
|
||||
"declined_prerequisites": [],
|
||||
"failed_prerequisites": [],
|
||||
"pending_prerequisites": [],
|
||||
"satisfied_prerequisites": [],
|
||||
},
|
||||
"time_limit_mins": null,
|
||||
"type": "",
|
||||
},
|
||||
"examAccessToken": Object {
|
||||
"examAccessToken": {
|
||||
"exam_access_token": "",
|
||||
"exam_access_token_expiration": "",
|
||||
},
|
||||
"isLoading": true,
|
||||
"proctoringSettings": Object {
|
||||
"exam_proctoring_backend": Object {
|
||||
"proctoringSettings": {
|
||||
"exam_proctoring_backend": {
|
||||
"download_url": "",
|
||||
"instructions": Array [],
|
||||
"instructions": [],
|
||||
"name": "",
|
||||
"rules": Object {},
|
||||
"rules": {},
|
||||
},
|
||||
"integration_specific_email": "",
|
||||
"learner_notification_from_email": "",
|
||||
@@ -916,7 +931,7 @@ Object {
|
||||
},
|
||||
"timeIsOver": false,
|
||||
},
|
||||
"tours": Object {
|
||||
"tours": {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
|
||||
@@ -18,7 +18,7 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
|
||||
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
||||
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
||||
// exists in edx-platform.
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
import {
|
||||
LOADING,
|
||||
LOADED,
|
||||
FAILED,
|
||||
DENIED,
|
||||
} from '@src/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'course-home',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import HeaderSlot from '../../plugin-slots/HeaderSlot';
|
||||
import PageLoading from '../../generic/PageLoading';
|
||||
import { unsubscribeFromCourseGoal } from '../data/api';
|
||||
|
||||
@@ -38,7 +38,7 @@ const GoalUnsubscribe = ({ intl }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header showUserDropdown={false} />
|
||||
<HeaderSlot showUserDropdown={false} />
|
||||
<main id="main-content" className="container my-5 text-center">
|
||||
{isLoading && (
|
||||
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -194,19 +195,27 @@ const OutlineTab = ({ intl }) => {
|
||||
/>
|
||||
)}
|
||||
<CourseTools />
|
||||
<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
|
||||
id="outline_tab_notifications_slot"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
model: 'outline',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<CourseDates />
|
||||
<CourseHandouts />
|
||||
</div>
|
||||
|
||||
@@ -132,6 +132,16 @@ 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,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -96,7 +95,7 @@ const SequenceLink = ({
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-success mt-1"
|
||||
aria-hidden="true"
|
||||
aria-hidden={complete}
|
||||
title={intl.formatMessage(messages.completedAssignment)}
|
||||
/>
|
||||
) : (
|
||||
@@ -104,7 +103,7 @@ const SequenceLink = ({
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-gray-400 mt-1"
|
||||
aria-hidden="true"
|
||||
aria-hidden={complete}
|
||||
title={intl.formatMessage(messages.incompleteAssignment)}
|
||||
/>
|
||||
)}
|
||||
@@ -118,14 +117,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)}
|
||||
<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>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
|
||||
@@ -19,6 +19,10 @@ const CertificateStatus = ({ intl }) => {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
entranceExamData,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
isEnrolled,
|
||||
org,
|
||||
@@ -42,6 +46,8 @@ const CertificateStatus = ({ intl }) => {
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
const entranceExamPassed = entranceExamData?.entranceExamPassed ?? null;
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
@@ -49,6 +55,7 @@ const CertificateStatus = ({ intl }) => {
|
||||
userHasPassingGrade,
|
||||
null, // CourseExitPageIsActive
|
||||
canViewCertificate,
|
||||
entranceExamPassed,
|
||||
);
|
||||
|
||||
const eventProperties = {
|
||||
|
||||
@@ -36,17 +36,17 @@
|
||||
}
|
||||
|
||||
#passing-grade-tooltip {
|
||||
background: $success-500;
|
||||
|
||||
.arrow::after {
|
||||
border-top-color: $success-500;
|
||||
}
|
||||
|
||||
background: $success-500;
|
||||
}
|
||||
|
||||
#non-passing-grade-tooltip {
|
||||
background: $accent-b;
|
||||
|
||||
.arrow::after {
|
||||
border-top-color: $accent-b;
|
||||
}
|
||||
|
||||
background: $accent-b;
|
||||
}
|
||||
|
||||
@@ -16,23 +16,27 @@ const CourseTabsNavigation = ({
|
||||
return (
|
||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="container-xl">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={url}
|
||||
<div className="nav-bar">
|
||||
<div className="nav-menu">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="course-tabs-navigation__search-toggle">
|
||||
<CoursewareSearchToggle />
|
||||
{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>
|
||||
</div>
|
||||
{show && <CoursewareSearch />}
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__search-toggle {
|
||||
position: absolute;
|
||||
top: .05rem;
|
||||
right: 0;
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-toggle {
|
||||
flex-grow: 0;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import Sequence from './sequence';
|
||||
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
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 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 { useModel } from '../../generic/model-store';
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import ContentTools from './content-tools';
|
||||
import Sequence from './sequence';
|
||||
|
||||
const Course = ({
|
||||
courseId,
|
||||
@@ -33,11 +32,12 @@ const Course = ({
|
||||
const {
|
||||
celebrations,
|
||||
isStaff,
|
||||
isNewDiscussionSidebarViewEnabled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
||||
const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR;
|
||||
const navigationDisabled = sequence?.navigationDisabled ?? false;
|
||||
const { enableNavigationSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
|
||||
const navigationDisabled = enableNavigationSidebar || (sequence?.navigationDisabled ?? false);
|
||||
|
||||
const pageTitleBreadCrumbs = [
|
||||
sequence,
|
||||
@@ -54,7 +54,6 @@ 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;
|
||||
|
||||
@@ -69,14 +68,14 @@ const Course = ({
|
||||
));
|
||||
}, [sequenceId]);
|
||||
|
||||
const SidebarProviderComponent = enableNewSidebar === 'true' ? NewSidebarProvider : SidebarProvider;
|
||||
const SidebarProviderComponent = isNewDiscussionSidebarViewEnabled ? 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-center mb-4 mt-1">
|
||||
<div className="position-relative d-flex align-items-xl-center mb-4 mt-1 flex-column flex-xl-row">
|
||||
{navigationDisabled || (
|
||||
<>
|
||||
<CourseBreadcrumbs
|
||||
@@ -100,11 +99,10 @@ const Course = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shouldDisplayTriggers && (
|
||||
<>
|
||||
{enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers /> }
|
||||
</>
|
||||
)}
|
||||
<div className="w-100 d-flex align-items-center">
|
||||
<CourseOutlineTrigger isMobileView />
|
||||
{isNewDiscussionSidebarViewEnabled ? <NewSidebarTriggers /> : <SidebarTriggers /> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertList topic="sequence" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Factory } from 'rosie';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
|
||||
import {
|
||||
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
@@ -32,8 +32,6 @@ celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
|
||||
|
||||
describe('Course', () => {
|
||||
let store;
|
||||
let getItemSpy;
|
||||
let setItemSpy;
|
||||
const mockData = {
|
||||
nextSequenceHandler: () => {},
|
||||
previousSequenceHandler: () => {},
|
||||
@@ -52,30 +50,27 @@ describe('Course', () => {
|
||||
global.innerWidth = breakpoints.extraLarge.minWidth;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
getItemSpy.mockRestore();
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('loads learning sequence', async () => {
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
loadUnit();
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
|
||||
const { models } = store.getState();
|
||||
const sequence = models.sequences[mockData.sequenceId];
|
||||
const section = models.sections[sequence.sectionId];
|
||||
const course = models.coursewareMeta[mockData.courseId];
|
||||
expect(document.title).toMatch(
|
||||
`${sequence.title} | ${section.title} | ${course.title} | edX`,
|
||||
);
|
||||
const { models } = store.getState();
|
||||
const sequence = models.sequences[mockData.sequenceId];
|
||||
const section = models.sections[sequence.sectionId];
|
||||
const course = models.coursewareMeta[mockData.courseId];
|
||||
expect(document.title).toMatch(
|
||||
`${sequence.title} | ${section.title} | ${course.title} | edX`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes breadcrumbs when navigation is disabled', async () => {
|
||||
@@ -114,9 +109,11 @@ describe('Course', () => {
|
||||
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays weekly goal celebration modal', async () => {
|
||||
@@ -132,37 +129,42 @@ describe('Course', () => {
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays notification trigger and toggles active class on click', async () => {
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('mt-3', { exact: true });
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
|
||||
waitFor(() => {
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles click to open/close discussions sidebar', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
|
||||
|
||||
expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
const discussionsTrigger = screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
expect(discussionsTrigger).toBeInTheDocument();
|
||||
fireEvent.click(discussionsTrigger);
|
||||
});
|
||||
await expect(discussionsSideBar).toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(discussionsTrigger);
|
||||
|
||||
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
});
|
||||
await expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('displays discussions sidebar when unit changes', async () => {
|
||||
@@ -181,9 +183,9 @@ describe('Course', () => {
|
||||
const { rerender } = render(<Course {...testData} />, { store: testStore });
|
||||
loadUnit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
waitFor(() => {
|
||||
expect(screen.findByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.findByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
rerender(null);
|
||||
@@ -191,10 +193,13 @@ describe('Course', () => {
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
waitFor(() => {
|
||||
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders course breadcrumbs as expected', async () => {
|
||||
@@ -204,7 +209,9 @@ describe('Course', () => {
|
||||
{ type: 'vertical' },
|
||||
{ courseId: courseMetadata.id },
|
||||
));
|
||||
const testStore = await initializeTestStore({ courseMetadata, unitBlocks }, false);
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, enableNavigationSidebar: { enable_navigation_sidebar: false },
|
||||
}, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
@@ -216,10 +223,14 @@ describe('Course', () => {
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
});
|
||||
// expect the section and sequence "titles" to be loaded in as breadcrumb labels.
|
||||
expect(screen.getByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
|
||||
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(screen.findByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
|
||||
expect(screen.findByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes handlers to the sequence', async () => {
|
||||
@@ -248,14 +259,16 @@ describe('Course', () => {
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
|
||||
|
||||
// We are in the middle of the sequence, so no
|
||||
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
// We are in the middle of the sequence, so no
|
||||
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sequence alerts display', () => {
|
||||
@@ -275,7 +288,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
waitFor(() => expect(screen.findByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with passing score', async () => {
|
||||
@@ -309,7 +322,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
waitFor(() => expect(screen.findByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with non-passing score', async () => {
|
||||
@@ -343,7 +356,7 @@ describe('Course', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
waitFor(() => expect(screen.findByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,7 +375,7 @@ describe('Course', () => {
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
await expect(chat).toBeInTheDocument();
|
||||
waitFor(() => expect(chat).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not display chat when screen is too narrow (mobile)', async () => {
|
||||
|
||||
@@ -154,7 +154,7 @@ const CourseBreadcrumbs = ({
|
||||
}, [courseStatus, sequenceStatus, allSequencesInSections]);
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10">
|
||||
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10 mb-3">
|
||||
<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,6 +5,7 @@ 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 = ({
|
||||
@@ -20,21 +21,10 @@ 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].some(mode => mode === enrollmentMode)
|
||||
&& VERIFIED_MODES.includes(enrollmentMode)
|
||||
);
|
||||
|
||||
const validDates = () => {
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import truncate from 'truncate-html';
|
||||
import { FAILED, LOADED, LOADING } from '@src/constants';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import fetchCourseRecommendations from './data/thunks';
|
||||
import { FAILED, LOADED, LOADING } from './data/slice';
|
||||
import CatalogSuggestion from './CatalogSuggestion';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { logClick } from './utils';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
import {
|
||||
LOADING,
|
||||
LOADED,
|
||||
FAILED,
|
||||
} from '@src/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
courseId: null,
|
||||
|
||||
@@ -9,6 +9,7 @@ const COURSE_EXIT_MODES = {
|
||||
celebration: 1,
|
||||
nonPassing: 2,
|
||||
inProgress: 3,
|
||||
entranceExamFail: 4,
|
||||
};
|
||||
|
||||
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
|
||||
@@ -32,9 +33,14 @@ function getCourseExitMode(
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive = null,
|
||||
canImmediatelyViewCertificate = false,
|
||||
entranceExamPassed = null,
|
||||
) {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
|
||||
if (entranceExamPassed === false) {
|
||||
return COURSE_EXIT_MODES.entranceExamFail;
|
||||
}
|
||||
|
||||
if (courseExitPageIsActive === false || !authenticatedUser || !isEnrolled) {
|
||||
return COURSE_EXIT_MODES.disabled;
|
||||
}
|
||||
@@ -73,6 +79,7 @@ function GetCourseExitNavigation(courseId, intl) {
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
entranceExamData: { entranceExamPassed },
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
const { canViewCertificate } = useModel('courseHomeMeta', courseId);
|
||||
const exitMode = getCourseExitMode(
|
||||
@@ -82,8 +89,15 @@ function GetCourseExitNavigation(courseId, intl) {
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
canViewCertificate,
|
||||
entranceExamPassed,
|
||||
);
|
||||
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;
|
||||
|
||||
/** exitActive is used to enable/disable the exit i.e. next buttons.
|
||||
COURSE_EXIT_MODES denote the current status of the course.
|
||||
Available COURSE_EXIT_MODES: disabled, celebration, nonPassing, inProgress, entranceExamFail
|
||||
If the user fails the entrance exam,
|
||||
access to further course sections should not be allowed i.e. disable the next buttons. */
|
||||
const exitActive = ((exitMode !== COURSE_EXIT_MODES.disabled) && (exitMode !== COURSE_EXIT_MODES.entranceExamFail));
|
||||
|
||||
let exitText;
|
||||
switch (exitMode) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { SIDEBARS } from './sidebars';
|
||||
const Sidebar = () => {
|
||||
const { currentSidebar, isDiscussionbarAvailable, isNotificationbarAvailable } = useContext(SidebarContext);
|
||||
|
||||
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)) { return null; }
|
||||
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)
|
||||
|| !SIDEBARS[currentSidebar]) { return null; }
|
||||
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
|
||||
|
||||
return (
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const SidebarContext = React.createContext({});
|
||||
|
||||
export default SidebarContext;
|
||||
37
src/courseware/course/new-sidebar/SidebarContext.ts
Normal file
37
src/courseware/course/new-sidebar/SidebarContext.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import type { WIDGETS } from '@src/constants';
|
||||
import type { SIDEBARS } from './sidebars';
|
||||
|
||||
export type SidebarId = keyof typeof SIDEBARS;
|
||||
export type WidgetId = keyof typeof WIDGETS;
|
||||
export type UpgradeNotificationState = (
|
||||
| 'accessLastHour'
|
||||
| 'accessHoursLeft'
|
||||
| 'accessDaysLeft'
|
||||
| 'FPDdaysLeft'
|
||||
| 'FPDLastHour'
|
||||
| 'accessDateView'
|
||||
| 'PastExpirationDate'
|
||||
);
|
||||
|
||||
export interface SidebarContextData {
|
||||
toggleSidebar: (sidebarId?: SidebarId | null, widgetId?: WidgetId | null) => void;
|
||||
onNotificationSeen: () => void;
|
||||
setNotificationStatus: React.Dispatch<'active' | 'inactive'>;
|
||||
currentSidebar: SidebarId | null;
|
||||
notificationStatus: 'active' | 'inactive';
|
||||
upgradeNotificationCurrentState: UpgradeNotificationState;
|
||||
setUpgradeNotificationCurrentState: React.Dispatch<UpgradeNotificationState>;
|
||||
shouldDisplaySidebarOpen: boolean;
|
||||
shouldDisplayFullScreen: boolean;
|
||||
courseId: string;
|
||||
unitId: string;
|
||||
hideDiscussionbar: boolean;
|
||||
hideNotificationbar: boolean;
|
||||
isNotificationbarAvailable: boolean;
|
||||
isDiscussionbarAvailable: boolean;
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextData>({} as SidebarContextData);
|
||||
|
||||
export default SidebarContext;
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, {
|
||||
useCallback, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
@@ -9,20 +8,35 @@ 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';
|
||||
|
||||
const SidebarProvider = ({
|
||||
interface Props {
|
||||
courseId: string;
|
||||
unitId: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SidebarProvider: React.FC<Props> = ({
|
||||
courseId,
|
||||
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 initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true')
|
||||
? SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID : null;
|
||||
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 [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [hideDiscussionbar, setHideDiscussionbar] = useState(false);
|
||||
@@ -30,8 +44,6 @@ 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);
|
||||
|
||||
@@ -43,7 +55,9 @@ const SidebarProvider = ({
|
||||
useEffect(() => {
|
||||
setHideDiscussionbar(!isDiscussionbarAvailable);
|
||||
setHideNotificationbar(!isNotificationbarAvailable);
|
||||
setCurrentSidebar(SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID);
|
||||
if (initialSidebar && currentSidebar !== initialSidebar) {
|
||||
setCurrentSidebar(SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID);
|
||||
}
|
||||
}, [unitId, topic]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,16 +66,39 @@ 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) {
|
||||
setHideDiscussionbar(prevWidgetId => (widgetId === WIDGETS.DISCUSSIONS ? true : prevWidgetId));
|
||||
setHideNotificationbar(prevWidgetId => (widgetId === WIDGETS.NOTIFICATIONS ? true : prevWidgetId));
|
||||
handleWidgetToggle(widgetId, sidebarId);
|
||||
} else {
|
||||
setCurrentSidebar(prevSidebar => (sidebarId === prevSidebar ? null : sidebarId));
|
||||
setHideDiscussionbar(!isDiscussionbarAvailable);
|
||||
setHideNotificationbar(!isNotificationbarAvailable);
|
||||
handleSidebarToggle(sidebarId);
|
||||
}
|
||||
}, [isDiscussionbarAvailable, isNotificationbarAvailable]);
|
||||
|
||||
clearSidebarKeyIfWidgetsUnavailable(widgetId);
|
||||
}, [handleWidgetToggle, handleSidebarToggle, clearSidebarKeyIfWidgetsUnavailable]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
toggleSidebar,
|
||||
@@ -90,14 +127,4 @@ const SidebarProvider = ({
|
||||
);
|
||||
};
|
||||
|
||||
SidebarProvider.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
SidebarProvider.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
export default SidebarProvider;
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -8,20 +7,32 @@ 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';
|
||||
import SidebarContext, { type SidebarId } from '../SidebarContext';
|
||||
|
||||
const SidebarBase = ({
|
||||
title,
|
||||
interface Props {
|
||||
title?: string;
|
||||
ariaLabel: string;
|
||||
sidebarId: SidebarId;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
showTitleBar?: boolean;
|
||||
width?: string;
|
||||
allowFullHeight?: boolean;
|
||||
showBorder?: boolean;
|
||||
}
|
||||
|
||||
const SidebarBase: React.FC<Props> = ({
|
||||
title = '',
|
||||
ariaLabel,
|
||||
sidebarId,
|
||||
className,
|
||||
children,
|
||||
showTitleBar,
|
||||
width,
|
||||
allowFullHeight,
|
||||
showBorder,
|
||||
showTitleBar = true,
|
||||
width = '45rem',
|
||||
allowFullHeight = false,
|
||||
showBorder = true,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
@@ -41,7 +52,7 @@ const SidebarBase = ({
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classNames('ml-0 ml-lg-4 h-auto align-top', {
|
||||
className={classNames('ml-0 ml-lg-4 h-auto align-top zindex-0', {
|
||||
'min-vh-100': !shouldDisplayFullScreen && allowFullHeight,
|
||||
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
|
||||
'd-none': currentSidebar !== sidebarId,
|
||||
@@ -58,8 +69,7 @@ const SidebarBase = ({
|
||||
onClick={() => toggleSidebar(null)}
|
||||
onKeyDown={() => toggleSidebar(null)}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
alt={intl.formatMessage(messages.responsiveCloseSidebarTray)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon src={ArrowBackIos} />
|
||||
<span className="font-weight-bold m-2 d-inline-block">
|
||||
@@ -90,25 +100,4 @@ const SidebarBase = ({
|
||||
);
|
||||
};
|
||||
|
||||
SidebarBase.propTypes = {
|
||||
title: PropTypes.string,
|
||||
ariaLabel: PropTypes.string.isRequired,
|
||||
sidebarId: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.element.isRequired,
|
||||
showTitleBar: PropTypes.bool,
|
||||
width: PropTypes.string,
|
||||
allowFullHeight: PropTypes.bool,
|
||||
showBorder: PropTypes.bool,
|
||||
};
|
||||
|
||||
SidebarBase.defaultProps = {
|
||||
title: '',
|
||||
width: '50rem',
|
||||
allowFullHeight: false,
|
||||
showTitleBar: true,
|
||||
className: '',
|
||||
showBorder: true,
|
||||
};
|
||||
|
||||
export default SidebarBase;
|
||||
@@ -1,6 +0,0 @@
|
||||
const WIDGETS = {
|
||||
DISCUSSIONS: 'DISCUSSIONS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
};
|
||||
|
||||
export default WIDGETS;
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const RightSidebarFilled = (props) => (
|
||||
<svg
|
||||
width={24}
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const RightSidebarOutlined = (props) => (
|
||||
<svg
|
||||
width={24}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user