Compare commits

..

81 Commits

Author SHA1 Message Date
Brian Smith
4edaa1a2a4 feat: update to header with new FPF pluginProps (#1525) 2024-11-22 15:21:03 -05:00
Brian Smith
6f1159617e feat: add frontend-plugin-framework header slot (#1504) 2024-10-23 11:45:57 -04:00
Brian Smith
8cc6b8cdde feat(deps): update header to 5.6.0 (#1502) 2024-10-22 19:19:47 -04:00
edX requirements bot
048488fb25 chore: update browserslist DB (#1499)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-10-21 11:08:06 -07:00
edX requirements bot
2a58ad2477 chore: update browserslist DB (#1497)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-10-15 10:46:25 -07:00
Mubbshar Anwar
798c51b4e7 fix: pass extra prop to plugin slot (#1494)
passing model as a prop to plugin slot for dynamic model selection
SONIC-717
2024-10-11 14:40:10 +05:00
Braden MacDonald
9a83d67d78 refactor: Enable TypeScript support in this repo (#1459) 2024-10-07 10:23:24 -07:00
edX requirements bot
356b183c5c chore: update browserslist DB (#1495)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-10-07 10:16:08 -07:00
Braden MacDonald
d64a4e448b chore(deps): bump sass to v1.79 and sass-loader to v16 (#1490) 2024-10-03 14:36:01 -07:00
Piotr Surowiec
860b3f9952 fix: send XBlock visibility status to the LMS (#1491) 2024-10-01 20:38:19 +05:30
edX requirements bot
4418c5422f chore: update browserslist DB (#1493)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-09-30 00:36:11 +00:00
dependabot[bot]
372c9de1db chore(deps): bump micromatch from 4.0.5 to 4.0.8 (#1469)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

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

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

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

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

* fix: change active selection based on user input

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

* fix: updated/resolved failing test

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

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

* refactor: removed eslint disable

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

---------

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

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

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

* fix: code refactors to resolve failing tests

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

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

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

* fix: increase adaptation brakepoint
2024-07-26 10:57:40 -04:00
sundasnoreen12
7efe8f5cc3 chore: make sidebar less intrusive for mobile (#1423)
* chore: make sidebar less intrusive for mobile

* fix: fixed first time view issue

* refactor: refactor code
2024-07-24 14:59:53 +05:00
Alison Langston
263fe6d1a2 chore: upgrade la library version (#1424) 2024-07-23 08:57:54 -04:00
Marcos Rigoli
76f98d5bb2 feat: Added experiments to Learning Assistant (#1421) 2024-07-19 12:33:13 -03:00
Zachary Hancock
e0386fe40b fix: update exams lib (#1422) 2024-07-19 10:56:13 -04:00
Kristin Aoki
7d99677acd feat: plugin slot to show non course content on next button click (#1412) 2024-07-17 21:05:43 -06:00
Marcos Rigoli
29bc2d9e17 chore: Revert Learning Assistant upgrade due to a bug (#1420) 2024-07-15 11:49:39 -03:00
Marcos Rigoli
27f3e79508 chore: Update learning assistant lib (#1419) 2024-07-15 11:10:55 -03:00
Zachary Hancock
ed74bee760 chore: update exams lib (#1418) 2024-07-11 14:30:03 -04:00
Emad Rad
c7a81fe07a chore: README updated
- code blocks added.
- heading issues fixed.
- small typos fixed.
2024-06-28 09:09:39 -04:00
Zachary Hancock
d880aac569 chore: update learning assistant lib (#1411) 2024-06-24 09:04:08 -04:00
Rodrigo Martin
072d608c64 feat(AU-2073): Trigger track event when iframe fails on load (#1407)
* feat(AU-2073): Trigger track event when iframe fails on load

* feat: fix tests

* fix: remove courseId from event
2024-06-04 16:48:21 -03:00
Ihor Romaniuk
9437142bc8 fix: optimize scroll position observer after video fullscreen exit (#1371) 2024-05-30 14:15:15 -03:00
Ihor Romaniuk
58c8ec5777 feat: [FC-0056] courseware sidebar enhancement (#1386)
- Display section and sequence progress
- Add tracking event to the unit button
- Hide the horizontal unit navigation with enabled sidebar navigation
2024-05-30 13:26:59 -03:00
Ahtisham Shahid
07357b9f10 feat: added role attrs in sidebar tracking event (#1399) 2024-05-30 12:06:21 +05:00
sundasnoreen12
1264b4245c fix: fixed width issue of incontext sidebar (#1395) 2024-05-27 13:43:31 +05:00
Zachary Hancock
e3ecee18e3 style: use brand style for search button border (#1394) 2024-05-23 13:08:48 -04:00
Varsha Menon
c3d96622e8 feat: update courseware search button (#1382) 2024-05-23 11:27:10 -04:00
Jorg Are
e577efbd27 feat: add unit title to pluginslot (#1383) 2024-05-21 17:20:51 +01:00
Rodrigo Martin
df361236d0 feat(AU-2006): Show loading state when translating unit block (#1389) 2024-05-17 13:48:23 -03:00
Brian Smith
e656f5445c feat!: organize plugin slots as components, add footer slot (#1381)
BREAKING CHANGE: slot ids have been changed for consistency
* `sequence_container_plugin` -> `sequence_container_slot`
* `unit_title_plugin` -> `unit_title_slot`
2024-05-17 12:09:47 -03:00
Zachary Hancock
f124c0d491 fix: incorrectly named plugin slots (#1388) 2024-05-16 10:24:27 -04:00
Leangseu Kim
cc041ba348 chore: lock test util to version 2.0.0 2024-05-15 15:42:24 -04:00
Leangseu Kim
257c9dcd7f chore: make sidebar less intrusive for mobile (#1377)
* chore: fix incorrect fetch result

* chore: make sidebar less intrusive for mobile

* chore: linting
2024-05-14 11:22:55 -04:00
Marcos Rigoli
1857b86c7e feat: Notification Plugin Slots (#1368)
* feat: add plugin slot for fbe lock paywall (#1347)

* feat: Added PluginSlot wrapping UpgradeNotification components (#1366)

* chore: Updated PluginSlot mock to support children and test ids

* chore: Updated mocked PluginSlot

* chore: Added unit test for MockedPluginSlot

* fix: Updated slot name ids

* feat: Added Plugin Slot wrapping UpgradeNotification in NotificationTray (#1367)

* fix: Removed PluginSlot prop scoping for UpgradeNotification (#1369)

* feat: generic sidebar notification plugin (#1379)

* feat: make notification plugin api generic

* feat: include new sidebar and tests

* feat: tweak sidebar toggle

* style: fix extra space from merge

* feat: rename plugin slots

---------

Co-authored-by: Alison Langston <46360176+alangsto@users.noreply.github.com>
Co-authored-by: Zachary Hancock <zhancock@edx.org>
2024-05-13 16:59:20 -04:00
142 changed files with 5890 additions and 10330 deletions

1
.env
View File

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

View File

@@ -4,6 +4,7 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
APP_ID='learning'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'

View File

@@ -4,6 +4,7 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
APP_ID='learning'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'

7
.github/dependabot.yml vendored Normal file
View File

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

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

View File

@@ -9,16 +9,35 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: code-coverage-report-${{ matrix.node }}
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
path: coverage/*.*
coverage:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
- name: Download code coverage results
uses: actions/download-artifact@v4
with:
node-version: ${{ env.NODE_VER }}
- run: make validate.ci
name: code-coverage-report-20
# When we're only using Node 20, replace the line above with the following:
# name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
token: ${{ secrets.CODECOV_TOKEN }}

2
.nvmrc
View File

@@ -1 +1 @@
18
20

View File

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

View File

@@ -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 = {
/*
@@ -107,7 +140,7 @@ This MFE can be customized using `Frontend Plugin Framework <https://github.com/
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,
@@ -133,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
@@ -146,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
@@ -162,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
@@ -186,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.

View File

@@ -13,6 +13,6 @@ metadata:
annotations:
openedx.org/arch-interest-groups: ""
spec:
owner: group:2u-aurora
owner: group:committers-frontend-app-learning
type: 'website'
lifecycle: 'production'

View File

@@ -9,12 +9,12 @@ const config = createConfig('jest', {
'src/i18n',
'src/.*\\.exp\\..*',
],
// see https://github.com/axios/axios/issues/5026
moduleNameMapper: {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
'@src/(.*)': '<rootDir>/src/$1',
// Explicit mapping to ensure Jest resolves the module correctly
'@edx/frontend-lib-special-exams': '<rootDir>/node_modules/@edx/frontend-lib-special-exams',
},
testTimeout: 30000,
globalSetup: "./global-setup.js",
@@ -26,7 +26,7 @@ const config = createConfig('jest', {
config.reporters = [...(config.reporters || []), ["jest-console-group-reporter", {
// change this setting if need to see less details for each test
// reportType: "summary" | "details",
// reportType: "summary" | "details",
// enable: true | false,
afterEachTest: {
enable: true,

13255
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +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",
@@ -31,28 +34,33 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-lib-learning-assistant": "^2.0.0",
"@edx/frontend-lib-special-exams": "^3.0.1",
"@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.1.2",
"@openedx/frontend-build": "14.1.2",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.3.0",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"buffer": "^6.0.3",
"classnames": "2.5.1",
"copy-webpack-plugin": "^11.0.0",
"husky": "7.0.4",
"joi": "^17.11.0",
"js-cookie": "3.0.5",
"lodash": "^4.17.21",
"lodash.camelcase": "4.3.0",
"patch-package": "^8.0.0",
"postcss-loader": "^8.1.1",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
"react": "17.0.2",
@@ -63,34 +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.1.4",
"@pact-foundation/pact": "^11.0.2",
"@testing-library/jest-dom": "5.16.5",
"@pact-foundation/pact": "^13.0.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"eslint-import-resolver-webpack": "^0.13.8",
"husky": "7.0.4",
"jest": "^26.6.3",
"jest-console-group-reporter": "^1.0.1",
"axios-mock-adapter": "2.0.0",
"bundlewatch": "^0.4.0",
"eslint-import-resolver-webpack": "^0.13.9",
"jest": "^29.7.0",
"jest-console-group-reporter": "^1.1.1",
"jest-when": "^3.6.0",
"patch-package": "^8.0.0",
"postcss-loader": "^8.1.1",
"rosie": "2.1.1",
"sass": "^1.72.0",
"sass-loader": "^14.1.1",
"source-map-loader": "^5.0.0",
"style-loader": "^3.3.4"
"rosie": "2.1.1"
},
"bundlewatch": {
"files": [
{
"path": "dist/*.js",
"maxSize": "1300kB"
}
],
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
}
}

View File

@@ -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,7 +33,7 @@ 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',
@@ -43,9 +44,15 @@ export const VERIFIED_MODES = [
'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;

View File

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

View File

@@ -1,29 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
Object {
"filters": Array [
Object {
{
"filters": [
{
"count": 7,
"key": "capa",
"label": "CAPA",
},
Object {
{
"count": 2,
"key": "sequence",
"label": "Sequence",
},
Object {
{
"count": 9,
"key": "text",
"label": "Text",
},
Object {
{
"count": 1,
"key": "unknown",
"label": "Unknown",
},
Object {
{
"count": 2,
"key": "video",
"label": "Video",
@@ -31,11 +31,11 @@ Object {
],
"maxScore": 3.4545178,
"ms": 5,
"results": Array [
Object {
"results": [
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
"location": Array [
"location": [
"Introduction",
"Demo Course Overview",
],
@@ -44,10 +44,10 @@ Object {
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
"location": Array [
"location": [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
@@ -57,10 +57,10 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
"location": Array [
"location": [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
@@ -70,10 +70,10 @@ Object {
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
"location": Array [
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Text input",
@@ -83,10 +83,10 @@ Object {
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
"location": Array [
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Pointing on a Picture",
@@ -96,10 +96,10 @@ Object {
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
"location": Array [
"location": [
"About Exams and Certificates",
"edX Exams",
"Getting Answers",
@@ -109,10 +109,10 @@ Object {
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
"location": Array [
"location": [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
@@ -122,10 +122,10 @@ Object {
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
"location": Array [
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Multiple Choice Questions",
@@ -135,10 +135,10 @@ Object {
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
"location": Array [
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Numerical Input",
@@ -148,10 +148,10 @@ Object {
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
"location": Array [
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Video Presentation Styles",
@@ -161,10 +161,10 @@ Object {
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
"location": Array [
"location": [
"Example Week 2: Get Interactive",
"Homework - Labs and Demos",
"Code Grader",
@@ -174,10 +174,10 @@ Object {
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
"location": Array [
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Interactive Questions",
@@ -187,10 +187,10 @@ Object {
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
"location": Array [
"location": [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
@@ -200,10 +200,10 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
"location": Array [
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Discussion Forums",
@@ -213,10 +213,10 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
"location": Array [
"location": [
"About Exams and Certificates",
"edX Exams",
"Overall Grade Performance",
@@ -226,10 +226,10 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
"location": Array [
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Homework - Find Your Study Buddy",
@@ -239,10 +239,10 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
"location": Array [
"location": [
"Example Week 3: Be Social",
"Homework - Find Your Study Buddy",
"Homework - Find Your Study Buddy",
@@ -252,10 +252,10 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
"location": Array [
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Be Social",
@@ -265,10 +265,10 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
"location": Array [
"location": [
"About Exams and Certificates",
"edX Exams",
"EdX Exams",
@@ -278,10 +278,10 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
},
Object {
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
"location": Array [
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"When Are Your Exams? ",
@@ -291,7 +291,7 @@ Object {
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
},
Object {
{
"contentHits": 0,
"id": "random-element-id",
"location": null,

View File

@@ -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.',
},

View File

@@ -1,8 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
@@ -12,13 +12,13 @@ Object {
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseware": {
"courseId": null,
"courseOutline": Object {},
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": Object {},
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
@@ -26,12 +26,12 @@ Object {
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
@@ -49,33 +49,33 @@ Object {
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"tabs": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
@@ -84,7 +84,7 @@ Object {
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
@@ -94,10 +94,10 @@ Object {
},
},
},
"dates": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"courseDateBlocks": Array [
Object {
"dates": {
"course-v1:edX+DemoX+Demo_Course": {
"courseDateBlocks": [
{
"date": "2020-05-01T17:59:41Z",
"dateType": "course-start-date",
"description": "",
@@ -106,7 +106,7 @@ Object {
"link": "",
"title": "Course Starts",
},
Object {
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
@@ -116,7 +116,7 @@ Object {
"learnerHasAccess": true,
"title": "Multi Badges Completed",
},
Object {
{
"assignmentType": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -125,7 +125,7 @@ Object {
"learnerHasAccess": true,
"title": "Multi Badges Past Due",
},
Object {
{
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -135,7 +135,7 @@ Object {
"link": "https://example.com/",
"title": "Both Past Due 1",
},
Object {
{
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -145,7 +145,7 @@ Object {
"link": "https://example.com/",
"title": "Both Past Due 2",
},
Object {
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
@@ -156,7 +156,7 @@ Object {
"link": "https://example.com/",
"title": "One Completed/Due 1",
},
Object {
{
"assignmentType": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -166,7 +166,7 @@ Object {
"link": "https://example.com/",
"title": "One Completed/Due 2",
},
Object {
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
@@ -177,7 +177,7 @@ Object {
"link": "https://example.com/",
"title": "Both Completed 1",
},
Object {
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
@@ -188,7 +188,7 @@ Object {
"link": "https://example.com/",
"title": "Both Completed 2",
},
Object {
{
"date": "2020-06-16T17:59:40.942669Z",
"dateType": "verified-upgrade-deadline",
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
@@ -197,7 +197,7 @@ Object {
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -207,7 +207,7 @@ Object {
"link": "https://example.com/",
"title": "One Verified 1",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -217,7 +217,7 @@ Object {
"link": "https://example.com/",
"title": "One Verified 2",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -227,7 +227,7 @@ Object {
"link": "https://example.com/",
"title": "ORA Verified 2",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -237,7 +237,7 @@ Object {
"link": "https://example.com/",
"title": "Both Verified 1",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -247,7 +247,7 @@ Object {
"link": "https://example.com/",
"title": "Both Verified 2",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -255,7 +255,7 @@ Object {
"learnerHasAccess": true,
"title": "One Unreleased 1",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -265,7 +265,7 @@ Object {
"link": "https://example.com/",
"title": "One Unreleased 2",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -274,7 +274,7 @@ Object {
"learnerHasAccess": true,
"title": "Both Unreleased 1",
},
Object {
{
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
@@ -283,7 +283,7 @@ Object {
"learnerHasAccess": true,
"title": "Both Unreleased 2",
},
Object {
{
"date": "2030-08-23T00:00:00Z",
"dateType": "course-end-date",
"description": "",
@@ -292,7 +292,7 @@ Object {
"link": "",
"title": "Course Ends",
},
Object {
{
"date": "2030-09-01T00:00:00Z",
"dateType": "verification-deadline-date",
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
@@ -302,7 +302,7 @@ Object {
"title": "Verification Deadline",
},
],
"datesBannerInfo": Object {
"datesBannerInfo": {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
@@ -314,16 +314,16 @@ Object {
},
},
},
"plugins": Object {},
"recommendations": Object {
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": Object {
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": Object {
"attempt": Object {
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
@@ -351,27 +351,27 @@ Object {
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": Object {
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": Object {
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": Array [],
"instructions": [],
"name": "",
"rules": Object {},
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
@@ -382,7 +382,7 @@ Object {
},
"timeIsOver": false,
},
"tours": Object {
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
@@ -393,8 +393,8 @@ Object {
`;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
@@ -404,13 +404,13 @@ Object {
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseware": {
"courseId": null,
"courseOutline": Object {},
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": Object {},
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
@@ -418,12 +418,12 @@ Object {
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
@@ -441,33 +441,33 @@ Object {
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"tabs": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
@@ -476,7 +476,7 @@ Object {
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
@@ -486,41 +486,41 @@ Object {
},
},
},
"outline": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"outline": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"canShowUpgradeSock": false,
"certData": Object {
"certData": {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
},
"courseBlocks": Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"courseBlocks": {
"courses": {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": {
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionIds": Array [
"sectionIds": [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"sections": {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course",
"hideFromTOC": undefined,
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": Array [
"sequenceIds": [
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
"title": "Title of Section",
},
},
"sequences": Object {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
"sequences": {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": {
"complete": false,
"description": null,
"due": null,
@@ -536,30 +536,30 @@ Object {
},
},
},
"courseGoals": Object {
"courseGoals": {
"daysPerWeek": null,
"goalOptions": Array [],
"goalOptions": [],
"selectedGoal": null,
"subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
},
"courseTools": Array [
Object {
"courseTools": [
{
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "https://example.com/bookmarks",
},
],
"datesBannerInfo": Object {
"datesBannerInfo": {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
},
"datesWidget": Object {
"courseDateBlocks": Array [],
"datesWidget": {
"courseDateBlocks": [],
},
"enableProctoredExams": undefined,
"enrollAlert": Object {
"enrollAlert": {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
@@ -569,13 +569,13 @@ Object {
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course",
"offer": null,
"resumeCourse": Object {
"resumeCourse": {
"hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
},
"timeOffsetMillis": 0,
"userHasPassingGrade": undefined,
"verifiedMode": Object {
"verifiedMode": {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
"currencySymbol": "$",
@@ -587,16 +587,16 @@ Object {
},
},
},
"plugins": Object {},
"recommendations": Object {
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": Object {
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": Object {
"attempt": Object {
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
@@ -624,27 +624,27 @@ Object {
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": Object {
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": Object {
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": Array [],
"instructions": [],
"name": "",
"rules": Object {},
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
@@ -655,7 +655,7 @@ Object {
},
"timeIsOver": false,
},
"tours": Object {
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
@@ -666,8 +666,8 @@ Object {
`;
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
@@ -677,13 +677,13 @@ Object {
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseware": {
"courseId": null,
"courseOutline": Object {},
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": Object {},
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
@@ -691,12 +691,12 @@ Object {
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": Object {
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
@@ -714,33 +714,33 @@ Object {
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"tabs": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
Object {
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
@@ -749,7 +749,7 @@ Object {
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
@@ -759,16 +759,16 @@ Object {
},
},
},
"progress": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"progress": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"certificateData": Object {},
"completionSummary": Object {
"certificateData": {},
"completionSummary": {
"completeCount": 1,
"incompleteCount": 1,
"lockedCount": 0,
},
"courseGrade": Object {
"courseGrade": {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
@@ -779,10 +779,10 @@ Object {
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": Object {
"assignmentPolicies": Array [
Object {
"averageGrade": "1.00",
"gradingPolicy": {
"assignmentPolicies": [
{
"averageGrade": "1.0000",
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
@@ -790,17 +790,17 @@ Object {
"weightedGrade": 1,
},
],
"gradeRange": Object {
"gradeRange": {
"pass": 0.75,
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionScores": Array [
Object {
"sectionScores": [
{
"displayName": "First section",
"subsections": Array [
Object {
"subsections": [
{
"assignmentType": "Homework",
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection",
@@ -809,16 +809,16 @@ Object {
"numPointsEarned": 0,
"numPointsPossible": 3,
"percentGraded": 0,
"problemScores": Array [
Object {
"problemScores": [
{
"earned": 0,
"possible": 1,
},
Object {
{
"earned": 0,
"possible": 1,
},
Object {
{
"earned": 0,
"possible": 1,
},
@@ -829,18 +829,18 @@ Object {
},
],
},
Object {
{
"displayName": "Second section",
"subsections": Array [
Object {
"subsections": [
{
"assignmentType": "Homework",
"displayName": "Second subsection",
"hasGradedAssignment": true,
"numPointsEarned": 1,
"numPointsPossible": 1,
"percentGraded": 1,
"problemScores": Array [
Object {
"problemScores": [
{
"earned": 1,
"possible": 1,
},
@@ -854,7 +854,7 @@ Object {
],
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
"userHasPassingGrade": false,
"verificationData": Object {
"verificationData": {
"link": null,
"status": "none",
"statusDate": null,
@@ -863,16 +863,16 @@ Object {
},
},
},
"plugins": Object {},
"recommendations": Object {
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": Object {
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": Object {
"attempt": Object {
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
@@ -900,27 +900,27 @@ Object {
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": Object {
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": Array [],
"failed_prerequisites": Array [],
"pending_prerequisites": Array [],
"satisfied_prerequisites": Array [],
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": Object {
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": Object {
"exam_proctoring_backend": Object {
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": Array [],
"instructions": [],
"name": "",
"rules": Object {},
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
@@ -931,7 +931,7 @@ Object {
},
"timeIsOver": false,
},
"tours": Object {
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,

View File

@@ -18,7 +18,7 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
// exists in edx-platform.
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,10 @@ const CertificateStatus = ({ intl }) => {
courseId,
} = useSelector(state => state.courseHome);
const {
entranceExamData,
} = useModel('coursewareMeta', courseId);
const {
isEnrolled,
org,
@@ -42,6 +46,8 @@ const CertificateStatus = ({ intl }) => {
certificateAvailableDate,
} = certificateData || {};
const entranceExamPassed = entranceExamData?.entranceExamPassed ?? null;
const mode = getCourseExitMode(
certificateData,
hasScheduledContent,
@@ -49,6 +55,7 @@ const CertificateStatus = ({ intl }) => {
userHasPassingGrade,
null, // CourseExitPageIsActive
canViewCertificate,
entranceExamPassed,
);
const eventProperties = {

View File

@@ -36,17 +36,17 @@
}
#passing-grade-tooltip {
background: $success-500;
.arrow::after {
border-top-color: $success-500;
}
background: $success-500;
}
#non-passing-grade-tooltip {
background: $accent-b;
.arrow::after {
border-top-color: $accent-b;
}
background: $accent-b;
}

View File

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

View File

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

View File

@@ -32,8 +32,6 @@ celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
describe('Course', () => {
let store;
let getItemSpy;
let setItemSpy;
const mockData = {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
@@ -52,30 +50,27 @@ describe('Course', () => {
global.innerWidth = breakpoints.extraLarge.minWidth;
});
afterAll(() => {
getItemSpy.mockRestore();
setItemSpy.mockRestore();
});
it('loads learning sequence', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
waitFor(() => {
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
loadUnit();
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
const { models } = store.getState();
const sequence = models.sequences[mockData.sequenceId];
const section = models.sections[sequence.sectionId];
const course = models.coursewareMeta[mockData.courseId];
expect(document.title).toMatch(
`${sequence.title} | ${section.title} | ${course.title} | edX`,
);
const { models } = store.getState();
const sequence = models.sequences[mockData.sequenceId];
const section = models.sections[sequence.sectionId];
const course = models.coursewareMeta[mockData.courseId];
expect(document.title).toMatch(
`${sequence.title} | ${section.title} | ${course.title} | edX`,
);
});
});
it('removes breadcrumbs when navigation is disabled', async () => {
@@ -114,9 +109,11 @@ describe('Course', () => {
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
waitFor(() => {
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
});
});
it('displays weekly goal celebration modal', async () => {
@@ -132,40 +129,40 @@ describe('Course', () => {
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
waitFor(() => {
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
});
});
it('displays notification trigger and toggles active class on click', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
waitFor(() => {
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
});
});
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
await waitFor(() => {
waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
});
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
expect(discussionsTrigger).toBeInTheDocument();
fireEvent.click(discussionsTrigger);
const discussionsTrigger = screen.getByRole('button', { name: /Show discussions tray/i });
expect(discussionsTrigger).toBeInTheDocument();
fireEvent.click(discussionsTrigger);
await waitFor(() => {
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).not.toBeInTheDocument();
});
fireEvent.click(discussionsTrigger);
fireEvent.click(discussionsTrigger);
await waitFor(() => {
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
});
});
@@ -186,9 +183,9 @@ describe('Course', () => {
const { rerender } = render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
waitFor(() => {
expect(screen.findByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.findByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
});
rerender(null);
@@ -196,11 +193,13 @@ describe('Course', () => {
it('handles click to open/close notification tray', async () => {
await setupDiscussionSidebar();
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
fireEvent.click(notificationShowButton);
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
waitFor(() => {
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
fireEvent.click(notificationShowButton);
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
});
});
it('renders course breadcrumbs as expected', async () => {
@@ -224,10 +223,14 @@ describe('Course', () => {
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
await waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
});
// expect the section and sequence "titles" to be loaded in as breadcrumb labels.
expect(screen.getByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
waitFor(() => {
expect(screen.findByText(Object.values(models.sections)[0].title)).toBeInTheDocument();
expect(screen.findByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
});
});
it('passes handlers to the sequence', async () => {
@@ -256,14 +259,16 @@ describe('Course', () => {
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
expect(nextSequenceHandler).not.toHaveBeenCalled();
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
expect(nextSequenceHandler).not.toHaveBeenCalled();
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
});
});
describe('Sequence alerts display', () => {
@@ -283,7 +288,7 @@ describe('Course', () => {
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
waitFor(() => expect(screen.findByText('Some random banner text to display.')).toBeInTheDocument());
});
it('renders Entrance Exam alert with passing score', async () => {
@@ -317,7 +322,7 @@ describe('Course', () => {
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
waitFor(() => expect(screen.findByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
});
it('renders Entrance Exam alert with non-passing score', async () => {
@@ -351,7 +356,7 @@ describe('Course', () => {
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
waitFor(() => expect(screen.findByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
});
});
@@ -370,7 +375,7 @@ describe('Course', () => {
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId);
await expect(chat).toBeInTheDocument();
waitFor(() => expect(chat).toBeInTheDocument());
});
it('does not display chat when screen is too narrow (mobile)', async () => {

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ const COURSE_EXIT_MODES = {
celebration: 1,
nonPassing: 2,
inProgress: 3,
entranceExamFail: 4,
};
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
@@ -32,9 +33,14 @@ function getCourseExitMode(
userHasPassingGrade,
courseExitPageIsActive = null,
canImmediatelyViewCertificate = false,
entranceExamPassed = null,
) {
const authenticatedUser = getAuthenticatedUser();
if (entranceExamPassed === false) {
return COURSE_EXIT_MODES.entranceExamFail;
}
if (courseExitPageIsActive === false || !authenticatedUser || !isEnrolled) {
return COURSE_EXIT_MODES.disabled;
}
@@ -73,6 +79,7 @@ function GetCourseExitNavigation(courseId, intl) {
isEnrolled,
userHasPassingGrade,
courseExitPageIsActive,
entranceExamData: { entranceExamPassed },
} = useModel('coursewareMeta', courseId);
const { canViewCertificate } = useModel('courseHomeMeta', courseId);
const exitMode = getCourseExitMode(
@@ -82,8 +89,15 @@ function GetCourseExitNavigation(courseId, intl) {
userHasPassingGrade,
courseExitPageIsActive,
canViewCertificate,
entranceExamPassed,
);
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;
/** exitActive is used to enable/disable the exit i.e. next buttons.
COURSE_EXIT_MODES denote the current status of the course.
Available COURSE_EXIT_MODES: disabled, celebration, nonPassing, inProgress, entranceExamFail
If the user fails the entrance exam,
access to further course sections should not be allowed i.e. disable the next buttons. */
const exitActive = ((exitMode !== COURSE_EXIT_MODES.disabled) && (exitMode !== COURSE_EXIT_MODES.entranceExamFail));
let exitText;
switch (exitMode) {

View File

@@ -6,7 +6,8 @@ import { SIDEBARS } from './sidebars';
const Sidebar = () => {
const { currentSidebar, isDiscussionbarAvailable, isNotificationbarAvailable } = useContext(SidebarContext);
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)) { return null; }
if (currentSidebar === null || (!isDiscussionbarAvailable && !isNotificationbarAvailable)
|| !SIDEBARS[currentSidebar]) { return null; }
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
return (

View File

@@ -1,5 +0,0 @@
import React from 'react';
const SidebarContext = React.createContext({});
export default SidebarContext;

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

View File

@@ -1,7 +1,6 @@
import React, {
useCallback, useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
@@ -13,16 +12,31 @@ 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;

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -10,18 +9,30 @@ import { ArrowBackIos, Close } from '@openedx/paragon/icons';
import { useEventListener } from '../../../../generic/hooks';
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 {
@@ -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;

View File

@@ -1,5 +1,3 @@
import * as React from 'react';
const RightSidebarFilled = (props) => (
<svg
width={24}

View File

@@ -1,5 +1,3 @@
import * as React from 'react';
const RightSidebarOutlined = (props) => (
<svg
width={24}

View File

@@ -17,7 +17,7 @@ const DiscussionsNotificationsSidebar = () => {
<SidebarBase
ariaLabel={intl.formatMessage(messages.discussionNotificationTray)}
sidebarId={ID}
className="d-flex flex-column flex-fill"
className="d-flex flex-column flex-fill overflow-auto"
showTitleBar={false}
showBorder={false}
>

View File

@@ -31,7 +31,7 @@ describe('DiscussionsWidget', () => {
excludeFetchSequence: false,
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const state = store.getState();
const state = store.getState() as any; // TODO: remove 'any' once redux state gets types
courseId = state.courseware.courseId;
[unitId] = Object.keys(state.models.units);

View File

@@ -3,6 +3,7 @@ import React, { useContext } from 'react';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import SidebarContext from '../../../SidebarContext';
import messages from '../../../messages';
@@ -24,7 +25,10 @@ const DiscussionsWidget = () => {
return (
<iframe
src={`${discussionsUrl}?inContextSidebar`}
className={classNames('d-flex w-100 flex-fill border border-light-400 rounded-sm', { 'vh-100': !shouldDisplayFullScreen })}
className={classNames('d-flex w-100 flex-fill border border-light-400 rounded-sm', {
'vh-100': !shouldDisplayFullScreen,
'min-height-700': shouldDisplayFullScreen,
})}
title={intl.formatMessage(messages.discussionsTitle)}
allow="clipboard-write"
loading="lazy"

View File

@@ -14,7 +14,7 @@ import {
import initializeStore from '../../../../../../store';
import { appendBrowserTimezoneToUrl, executeThunk } from '../../../../../../utils';
import { fetchCourse } from '../../../../../data';
import SidebarContext from '../../../SidebarContext';
import SidebarContext, { SidebarContextData } from '../../../SidebarContext';
import NotificationsWidget from './NotificationsWidget';
import setupDiscussionSidebar from '../../../../test-utils';
@@ -24,7 +24,7 @@ jest.mock('@edx/frontend-platform/analytics');
describe('NotificationsWidget', () => {
let axiosMock;
let store;
const ID = 'NEWSIDEBAR';
const ID = 'DISCUSSIONS_NOTIFICATIONS';
const defaultMetadata = Factory.build('courseMetadata');
const courseId = defaultMetadata.id;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
@@ -33,7 +33,7 @@ describe('NotificationsWidget', () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata');
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`);
function setMetadata(attributes, options) {
function setMetadata(attributes, options = undefined) {
const updatedCourseHomeMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseHomeMetadataUrl).reply(200, updatedCourseHomeMetadata);
}
@@ -78,35 +78,52 @@ describe('NotificationsWidget', () => {
});
});
it('renders upgrade card', async () => {
it('includes notification_widget_slot', async () => {
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
hideNotificationbar: false,
isNotificationbarAvailable: true,
}}
} as SidebarContextData}
>
<NotificationsWidget />
</SidebarContext.Provider>,
);
const UpgradeNotification = document.querySelector('.upgrade-notification');
expect(screen.getByTestId('notification_widget_slot')).toBeInTheDocument();
});
it('renders upgrade card', async () => {
const contextData: Partial<SidebarContextData> = {
currentSidebar: ID,
courseId,
hideNotificationbar: false,
isNotificationbarAvailable: true,
};
await fetchAndRender(
<SidebarContext.Provider value={contextData as SidebarContextData}>
<NotificationsWidget />
</SidebarContext.Provider>,
);
// The Upgrade Notification should be inside the PluginSlot.
const UpgradeNotification = document.querySelector('.upgrade-notification');
expect(UpgradeNotification).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
expect(screen.queryByText('You have no new notifications at this time.')).not.toBeInTheDocument();
});
it('renders no notifications bar if no verified mode', async () => {
setMetadata({ verified_mode: null });
const contextData: Partial<SidebarContextData> = {
currentSidebar: ID,
courseId,
hideNotificationbar: true,
isNotificationbarAvailable: false,
};
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
hideNotificationbar: true,
isNotificationbarAvailable: false,
}}
>
<SidebarContext.Provider value={contextData as SidebarContextData}>
<NotificationsWidget />
</SidebarContext.Provider>,
);
@@ -152,22 +169,20 @@ describe('NotificationsWidget', () => {
});
it('marks notification as seen 3 seconds later', async () => {
jest.useFakeTimers();
const onNotificationSeen = jest.fn();
const contextData: Partial<SidebarContextData> = {
currentSidebar: ID,
courseId,
onNotificationSeen,
hideNotificationbar: false,
isNotificationbarAvailable: true,
};
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
onNotificationSeen,
hideNotificationbar: false,
isNotificationbarAvailable: true,
}}
>
<SidebarContext.Provider value={contextData as SidebarContextData}>
<NotificationsWidget />
</SidebarContext.Provider>,
);
expect(onNotificationSeen).toHaveBeenCalledTimes(0);
jest.advanceTimersByTime(3000);
expect(onNotificationSeen).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onNotificationSeen).toHaveBeenCalledTimes(1), { timeout: 3500 });
});
});

View File

@@ -1,6 +1,7 @@
import React, { useContext, useEffect, useMemo } from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { useModel } from '../../../../../../generic/model-store';
import UpgradeNotification from '../../../../../../generic/upgrade-notification/UpgradeNotification';
import { WIDGETS } from '../../../../../../constants';
@@ -58,8 +59,13 @@ const NotificationsWidget = () => {
verification_status: verificationStatus,
};
const onToggleSidebar = () => {
toggleSidebar(currentSidebar, WIDGETS.NOTIFICATIONS);
};
// After three seconds, update notificationSeen (to hide red dot)
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
setTimeout(onNotificationSeen, 3000);
sendTrackEvent('edx.ui.course.upgrade.new_sidebar.notifications', notificationTrayEventProperties);
}, []);
@@ -68,22 +74,32 @@ const NotificationsWidget = () => {
return (
<div className="border border-light-400 rounded-sm" data-testid="notification-widget">
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="in_course"
userTimezone={userTimezone}
shouldDisplayBorder={false}
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
toggleSidebar={() => toggleSidebar(currentSidebar, WIDGETS.NOTIFICATIONS)}
/>
<PluginSlot
id="notification_widget_slot"
pluginProps={{
courseId,
model: 'coursewareMeta',
notificationCurrentState: upgradeNotificationCurrentState,
setNotificationCurrentState: setUpgradeNotificationCurrentState,
toggleSidebar: onToggleSidebar,
}}
>
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="in_course"
userTimezone={userTimezone}
shouldDisplayBorder={false}
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
toggleSidebar={onToggleSidebar}
/>
</PluginSlot>
</div>
);
};

View File

@@ -6,8 +6,8 @@ export const SIDEBARS = {
Sidebar: discussionsNotifications.Sidebar,
Trigger: discussionsNotifications.Trigger,
},
};
} as const;
export const SIDEBAR_ORDER = [
discussionsNotifications.ID,
];
] as const;

View File

@@ -1,4 +1,4 @@
/* eslint-disable no-use-before-define */
/* eslint-disable @typescript-eslint/no-use-before-define */
import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
@@ -9,6 +9,7 @@ import {
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { useToggle } from '@openedx/paragon';
import PageLoading from '@src/generic/PageLoading';
import { useModel } from '@src/generic/model-store';
@@ -37,6 +38,7 @@ const Sequence = ({
previousSequenceHandler,
}) => {
const intl = useIntl();
const [isOpen, open, close] = useToggle();
const {
canAccessProctoredExams,
license,
@@ -51,7 +53,6 @@ const Sequence = ({
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
const { enableNavigationSidebar: isEnabledOutlineSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
const handleNext = () => {
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
const newUnitId = sequence.unitIds[nextIndex];
@@ -185,6 +186,13 @@ const Sequence = ({
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
{...{
nextSequenceHandler,
handleNavigate,
isOpen,
open,
close,
}}
/>
</div>
)}

View File

@@ -3,7 +3,7 @@ import { Factory } from 'rosie';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { breakpoints } from '@openedx/paragon';
import {
loadUnit, render, screen, fireEvent, waitFor, initializeTestStore,
loadUnit, render, screen, fireEvent, waitFor, initializeTestStore, act,
} from '../../../setupTest';
import SidebarContext from '../sidebar/SidebarContext';
import Sequence from './Sequence';
@@ -103,18 +103,20 @@ describe('Sequence', () => {
{ store: testStore, wrapWithRouter: true },
);
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
// `Previous`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button').length).toEqual(3);
// `Next` button.
expect(screen.getAllByRole('link').length).toEqual(1);
waitFor(() => {
expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument();
// `Previous`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button').length).toEqual(3);
// `Next` button.
expect(screen.getAllByRole('link').length).toEqual(1);
expect(screen.getByText('Content Locked')).toBeInTheDocument();
const unitContainer = container.querySelector('.unit-container');
expect(unitContainer.querySelector('svg')).toHaveClass('fa-lock');
expect(screen.getByText(/You must complete the prerequisite/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Go To Prerequisite Section' })).toBeInTheDocument();
expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument();
expect(screen.getByText('Content Locked')).toBeInTheDocument();
const unitContainer = container.querySelector('.unit-container');
expect(unitContainer.querySelector('svg')).toHaveClass('fa-lock');
expect(screen.getByText(/You must complete the prerequisite/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Go To Prerequisite Section' })).toBeInTheDocument();
expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument();
});
});
it('renders correctly for hidden after due content', async () => {
@@ -157,19 +159,21 @@ describe('Sequence', () => {
it('handles loading unit', async () => {
render(<SidebarWrapper />, { wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// `Previous`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button')).toHaveLength(3);
// Renders `Next` button.
expect(screen.getAllByRole('link')).toHaveLength(1);
waitFor(() => {
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// `Previous`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button')).toHaveLength(3);
// Renders `Next` button.
expect(screen.getAllByRole('link')).toHaveLength(1);
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// At this point there will be 2 `Previous` and 2 `Next` buttons.
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
// Renders two `Next` buttons for top and bottom unit navigations.
expect(screen.getAllByRole('link')).toHaveLength(2);
loadUnit();
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
// At this point there will be 2 `Previous` and 2 `Next` buttons.
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
// Renders two `Next` buttons for top and bottom unit navigations.
expect(screen.getAllByRole('link')).toHaveLength(2);
});
});
describe('sequence and unit navigation buttons', () => {
@@ -201,31 +205,33 @@ describe('Sequence', () => {
previousSequenceHandler: jest.fn(),
};
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
waitFor(() => {
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
fireEvent.click(sequencePreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.previous_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: unitBlocks.length,
widget_placement: 'top',
});
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
fireEvent.click(sequencePreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.previous_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: unitBlocks.length,
widget_placement: 'top',
});
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
.filter(button => button !== sequencePreviousButton)[0];
fireEvent.click(unitPreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: unitBlocks.length,
widget_placement: 'bottom',
loadUnit();
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
.filter(button => button !== sequencePreviousButton)[0];
fireEvent.click(unitPreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: unitBlocks.length,
widget_placement: 'bottom',
});
});
});
@@ -237,30 +243,31 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(),
};
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
waitFor(() => {
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
fireEvent.click(sequenceNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
current_tab: unitBlocks.length,
id: testData.unitId,
tab_count: unitBlocks.length,
widget_placement: 'top',
});
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
fireEvent.click(sequenceNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
current_tab: unitBlocks.length,
id: testData.unitId,
tab_count: unitBlocks.length,
widget_placement: 'top',
});
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
.filter(button => button !== sequenceNextButton)[0];
fireEvent.click(unitNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.next_selected', {
current_tab: unitBlocks.length,
id: testData.unitId,
tab_count: unitBlocks.length,
widget_placement: 'bottom',
loadUnit();
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
.filter(button => button !== sequenceNextButton)[0];
fireEvent.click(unitNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.next_selected', {
current_tab: unitBlocks.length,
id: testData.unitId,
tab_count: unitBlocks.length,
widget_placement: 'bottom',
});
});
});
@@ -275,19 +282,22 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(),
};
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
waitFor(() => {
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
// As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here.
// Therefore the next unit will still be `the initial one + 1`.
expect(testData.unitNavigationHandler).toHaveBeenNthCalledWith(2, unitBlocks[unitNumber + 1].id);
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
// As `previousSequenceHandler` and `nextSequenceHandler` are mocked,
// we aren't really changing the position here.
// Therefore the next unit will still be `the initial one + 1`.
expect(testData.unitNavigationHandler).toHaveBeenNthCalledWith(2, unitBlocks[unitNumber + 1].id);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
});
});
it('handles the `Previous` buttons for the first unit in the first sequence', async () => {
@@ -300,13 +310,15 @@ describe('Sequence', () => {
};
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
expect(sendTrackEvent).not.toHaveBeenCalled();
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
expect(sendTrackEvent).not.toHaveBeenCalled();
});
});
it('handles the `Next` buttons for the last unit in the last sequence', async () => {
@@ -319,13 +331,15 @@ describe('Sequence', () => {
};
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
expect(sendTrackEvent).not.toHaveBeenCalled();
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
expect(sendTrackEvent).not.toHaveBeenCalled();
});
});
it('handles the navigation buttons for empty sequence', async () => {
@@ -365,39 +379,42 @@ describe('Sequence', () => {
render(<SidebarWrapper overrideData={testData} />, { store: innerTestStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(2);
waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(4);
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.lms.sequence.previous_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: 0,
widget_placement: 'top',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: 0,
widget_placement: 'bottom',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: 0,
widget_placement: 'top',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: 0,
widget_placement: 'bottom',
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(4);
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.lms.sequence.previous_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: 0,
widget_placement: 'top',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: 0,
widget_placement: 'bottom',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: 0,
widget_placement: 'top',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', {
current_tab: 1,
id: testData.unitId,
tab_count: 0,
widget_placement: 'bottom',
});
});
});
@@ -412,16 +429,17 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
};
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
current_tab: currentTabNumber,
id: testData.unitId,
target_tab: targetUnitNumber,
tab_count: unitBlocks.length,
widget_placement: 'top',
waitFor(() => {
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
current_tab: currentTabNumber,
id: testData.unitId,
target_tab: targetUnitNumber,
tab_count: unitBlocks.length,
widget_placement: 'top',
});
});
});
});
@@ -429,15 +447,17 @@ describe('Sequence', () => {
describe('notification feature', () => {
it('renders notification tray in sequence', async () => {
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
expect(await screen.findByText('Notifications')).toBeInTheDocument();
waitFor(async () => expect(await screen.findByText('Notifications')).toBeInTheDocument());
});
it('handles click on notification tray close button', async () => {
const toggleNotificationTray = jest.fn();
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
fireEvent.click(notificationCloseIconButton);
expect(toggleNotificationTray).toHaveBeenCalled();
act(async () => {
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
fireEvent.click(notificationCloseIconButton);
expect(toggleNotificationTray).toHaveBeenCalled();
});
});
it('does not render notification tray in sequence by default if in responsive view', async () => {

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react';
import { StrictDict } from '@edx/react-unit-test-utils';
import { ModalDialog, Modal } from '@openedx/paragon';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import PageLoading from '@src/generic/PageLoading';
import * as hooks from './hooks';
@@ -35,6 +36,7 @@ const ContentIFrame = ({
elementId,
onLoaded,
title,
courseId,
}) => {
const {
handleIFrameLoad,
@@ -82,7 +84,17 @@ const ContentIFrame = ({
return (
<>
{(shouldShowContent && !hasLoaded) && (
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
showError ? <ErrorPage /> : (
<PluginSlot
id="content_iframe_loader_slot"
pluginProps={{
defaultLoaderComponent: <PageLoading srMessage={loadingMessage} />,
courseId,
}}
>
<PageLoading srMessage={loadingMessage} />
</PluginSlot>
)
)}
{shouldShowContent && (
<div className="unit-iframe-wrapper">
@@ -124,11 +136,13 @@ ContentIFrame.propTypes = {
elementId: PropTypes.string.isRequired,
onLoaded: PropTypes.func,
title: PropTypes.node.isRequired,
courseId: PropTypes.string,
};
ContentIFrame.defaultProps = {
iframeUrl: null,
onLoaded: () => ({}),
courseId: '',
};
export default ContentIFrame;

View File

@@ -2,6 +2,7 @@ import React, { Suspense } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { useModel } from '@src/generic/model-store';
import PageLoading from '@src/generic/PageLoading';
@@ -24,19 +25,24 @@ const UnitSuspense = ({
meta.contentTypeGatingEnabled && unit.containsContentTypeGatedContent
);
const suspenseComponent = (message, Component) => (
<Suspense fallback={<PageLoading srMessage={formatMessage(message)} />}>
<Component courseId={courseId} />
</Suspense>
);
return (
<>
{shouldDisplayContentGating && (
suspenseComponent(messages.loadingLockedContent, LockPaywall)
<Suspense fallback={<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />}>
<PluginSlot
id="gated_unit_content_message_slot"
pluginProps={{
courseId,
}}
>
<LockPaywall courseId={courseId} />
</PluginSlot>
</Suspense>
)}
{shouldDisplayHonorCode && (
suspenseComponent(messages.loadingHonorCode, HonorCode)
<Suspense fallback={<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />}>
<HonorCode courseId={courseId} />
</Suspense>
)}
</>
);

View File

@@ -64,7 +64,7 @@ describe('UnitSuspense component', () => {
describe('output', () => {
describe('LockPaywall', () => {
const testNoPaywall = () => {
it('does not display LockPaywal', () => {
it('does not display LockPaywall', () => {
el = shallow(<UnitSuspense {...props} />);
expect(el.instance.findByType(LockPaywall).length).toEqual(0);
});
@@ -79,8 +79,9 @@ describe('UnitSuspense component', () => {
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
el = shallow(<UnitSuspense {...props} />);
const [component] = el.instance.findByType(LockPaywall);
expect(component.parent.type).toEqual('Suspense');
expect(component.parent.props.fallback)
expect(component.parent.type).toEqual('PluginSlot');
expect(component.parent.parent.type).toEqual('Suspense');
expect(component.parent.parent.props.fallback)
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />);
expect(component.props.courseId).toEqual(props.courseId);
});

View File

@@ -31,6 +31,7 @@ exports[`Unit component output snapshot: not bookmarked, do not show content 1`]
<UnitTitleSlot
courseId="test-course-id"
unitId="test-props-id"
unitTitle="unit-title"
/>
</div>
<h2
@@ -48,6 +49,7 @@ exports[`Unit component output snapshot: not bookmarked, do not show content 1`]
id="test-props-id"
/>
<ContentIFrame
courseId="test-course-id"
elementId="unit-iframe"
id="test-props-id"
loadingMessage="Loading learning sequence..."

View File

@@ -1,6 +1,8 @@
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import React from 'react';
import { useDispatch } from 'react-redux';
import { throttle } from 'lodash';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
@@ -84,6 +86,49 @@ const useIFrameBehavior = ({
useEventListener('message', receiveMessage);
// Send visibility status to the iframe. It's used to mark XBlocks as viewed.
React.useEffect(() => {
if (!hasLoaded) {
return undefined;
}
const iframeElement = document.getElementById(elementId);
if (!iframeElement || !iframeElement.contentWindow) {
return undefined;
}
const updateIframeVisibility = () => {
const rect = iframeElement.getBoundingClientRect();
const visibleInfo = {
type: 'unit.visibilityStatus',
data: {
topPosition: rect.top,
viewportHeight: window.innerHeight,
},
};
iframeElement.contentWindow.postMessage(
visibleInfo,
`${getConfig().LMS_BASE_URL}`,
);
};
// Throttle the update function to prevent it from sending too many messages to the iframe.
const throttledUpdateVisibility = throttle(updateIframeVisibility, 100);
// Update the visibility of the iframe in case the element is already visible.
updateIframeVisibility();
// Add event listeners to update the visibility of the iframe when the window is scrolled or resized.
window.addEventListener('scroll', throttledUpdateVisibility);
window.addEventListener('resize', throttledUpdateVisibility);
// Clean up event listeners on unmount.
return () => {
window.removeEventListener('scroll', throttledUpdateVisibility);
window.removeEventListener('resize', throttledUpdateVisibility);
};
}, [hasLoaded, elementId]);
/**
* onLoad *should* only fire after everything in the iframe has finished its own load events.
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
@@ -94,6 +139,10 @@ const useIFrameBehavior = ({
const handleIFrameLoad = () => {
if (!hasLoaded) {
setShowError(true);
sendTrackEvent('edx.bi.error.learning.iframe_load_failed', {
iframeUrl,
unitId: id,
});
logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', {
iframeUrl,
});
@@ -105,6 +154,11 @@ const useIFrameBehavior = ({
};
};
React.useEffect(() => {
setIframeHeight(0);
setHasLoaded(false);
}, [iframeUrl]);
return {
iframeHeight,
handleIFrameLoad,

View File

@@ -5,6 +5,7 @@ import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { fetchCourse } from '@src/courseware/data';
import { processEvent } from '@src/course-home/data/thunks';
import { useEventListener } from '@src/generic/hooks';
@@ -17,6 +18,8 @@ jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('@edx/frontend-platform/analytics');
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
@@ -27,6 +30,11 @@ jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
}));
jest.mock('lodash', () => ({
...jest.requireActual('lodash'),
throttle: jest.fn((fn) => fn),
}));
jest.mock('./useLoadBearingHook', () => jest.fn());
jest.mock('@edx/frontend-platform/logging', () => ({
@@ -61,7 +69,10 @@ const dispatch = jest.fn();
useDispatch.mockReturnValue(dispatch);
const postMessage = jest.fn();
const frame = { contentWindow: { postMessage } };
const frame = {
contentWindow: { postMessage },
getBoundingClientRect: jest.fn(() => ({ top: 100 })),
};
const mockGetElementById = jest.fn(() => frame);
const testHash = '#test-hash';
@@ -84,6 +95,10 @@ describe('useIFrameBehavior hook', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
global.document.getElementById = mockGetElementById;
global.window.addEventListener = jest.fn();
global.window.removeEventListener = jest.fn();
global.window.innerHeight = 800;
});
afterEach(() => {
state.resetVals();
@@ -262,6 +277,53 @@ describe('useIFrameBehavior hook', () => {
});
});
});
describe('visibility tracking', () => {
it('sets up visibility tracking after iframe has loaded', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
useIFrameBehavior(props);
const effects = getEffects([true, props.elementId], React);
expect(effects.length).toEqual(2);
effects[0](); // Execute the visibility tracking effect.
expect(global.window.addEventListener).toHaveBeenCalledTimes(2);
expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
// Initial visibility update.
expect(postMessage).toHaveBeenCalledWith(
{
type: 'unit.visibilityStatus',
data: {
topPosition: 100,
viewportHeight: 800,
},
},
config.LMS_BASE_URL,
);
});
it('does not set up visibility tracking before iframe has loaded', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: false });
useIFrameBehavior(props);
const effects = getEffects([false, props.elementId], React);
expect(effects).toBeNull();
expect(global.window.addEventListener).not.toHaveBeenCalled();
expect(postMessage).not.toHaveBeenCalled();
});
it('cleans up event listeners on unmount', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
useIFrameBehavior(props);
const effects = getEffects([true, props.elementId], React);
const cleanup = effects[0](); // Execute the effect and get the cleanup function.
cleanup(); // Call the cleanup function.
expect(global.window.removeEventListener).toHaveBeenCalledTimes(2);
expect(global.window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(global.window.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
});
});
});
describe('output', () => {
describe('handleIFrameLoad', () => {
@@ -271,6 +333,16 @@ describe('useIFrameBehavior hook', () => {
expect(state.setState.showError).toHaveBeenCalledWith(true);
expect(logError).toHaveBeenCalled();
});
it('sends track event if has not loaded', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
const eventName = 'edx.bi.error.learning.iframe_load_failed';
const eventProperties = {
unitId: props.id,
iframeUrl: props.iframeUrl,
};
expect(sendTrackEvent).toHaveBeenCalledWith(eventName, eventProperties);
});
it('does not set/log errors if loaded', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
@@ -278,6 +350,12 @@ describe('useIFrameBehavior hook', () => {
expect(state.setState.showError).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
});
it('does not send track event if loaded', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
expect(sendTrackEvent).not.toHaveBeenCalled();
});
it('registers an event handler to process fetchCourse events.', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();

View File

@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -23,6 +24,7 @@ const Unit = ({
id,
}) => {
const { formatMessage } = useIntl();
const [searchParams] = useSearchParams();
const { authenticatedUser } = React.useContext(AppContext);
const examAccess = useExamAccess({ id });
const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id });
@@ -35,6 +37,7 @@ const Unit = ({
view,
format,
examAccess,
jumpToId: searchParams.get('jumpToId'),
}));
const iframeUrl = getUrl();
@@ -43,7 +46,7 @@ const Unit = ({
<div className="unit">
<div className="mb-0">
<h3 className="h3">{unit.title}</h3>
<UnitTitleSlot courseId={courseId} unitId={id} />
<UnitTitleSlot courseId={courseId} unitId={id} unitTitle={unit.title} />
</div>
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
<BookmarkButton
@@ -60,6 +63,7 @@ const Unit = ({
onLoaded={onLoaded}
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
title={unit.title}
courseId={courseId}
/>
</div>
);

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { when } from 'jest-when';
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
import { useSearchParams } from 'react-router-dom';
import { useModel } from '@src/generic/model-store';
@@ -14,6 +15,7 @@ import { modelKeys, views } from './constants';
import * as hooks from './hooks';
jest.mock('./hooks', () => ({ useUnitData: jest.fn() }));
jest.mock('react-router-dom');
jest.mock('@edx/frontend-platform/i18n', () => {
const utils = jest.requireActual('@edx/react-unit-test-utils/dist');
@@ -82,7 +84,11 @@ when(useModel)
let el;
describe('Unit component', () => {
const searchParams = { get: (prop) => prop };
const setSearchParams = jest.fn();
beforeEach(() => {
useSearchParams.mockImplementation(() => [searchParams, setSearchParams]);
jest.clearAllMocks();
el = shallow(<Unit {...props} />);
});

View File

@@ -1,5 +1,5 @@
import { getConfig } from '@edx/frontend-platform';
import { stringify } from 'query-string';
import { stringifyUrl } from 'query-string';
export const iframeParams = {
show_title: 0,
@@ -12,15 +12,20 @@ export const getIFrameUrl = ({
view,
format,
examAccess,
jumpToId,
}) => {
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
const params = stringify({
...iframeParams,
view,
...(format && { format }),
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
return stringifyUrl({
url: xblockUrl,
query: {
...iframeParams,
view,
...(format && { format }),
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
jumpToId, // Pass jumpToId as query param as fragmentIdentifier is not passed to server.
},
fragmentIdentifier: jumpToId, // this is used by browser to scroll to correct block.
});
return `${xblockUrl}?${params}`;
};
export default {

View File

@@ -1,12 +1,12 @@
import { getConfig } from '@edx/frontend-platform';
import { stringify } from 'query-string';
import { stringifyUrl } from 'query-string';
import { getIFrameUrl, iframeParams } from './urls';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('query-string', () => ({
stringify: jest.fn((...args) => ({ stringify: args })),
stringifyUrl: jest.fn((arg) => ({ stringifyUrl: arg })),
}));
const config = { LMS_BASE_URL: 'test-lms-url' };
@@ -21,41 +21,43 @@ const props = {
describe('urls module getIFrameUrl', () => {
test('format provided, exam access and token available', () => {
const params = stringify({
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
const url = stringifyUrl({
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
query: {
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
},
});
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
expect(getIFrameUrl(props)).toEqual(url);
});
test('no format provided, exam access blocked', () => {
const params = stringify({ ...iframeParams, view: props.view });
const url = stringifyUrl({
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
query: { ...iframeParams, view: props.view },
});
expect(getIFrameUrl({
id: props.id,
view: props.view,
examAccess: { blockAccess: true },
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
})).toEqual(url);
});
test('src and dest languages provided', () => {
const params = stringify({
...iframeParams,
view: props.view,
src_lang: 'test-src-lang',
dest_lang: 'test-dest-lang',
test('jumpToId and fragmentIdentifier is added to url', () => {
const url = stringifyUrl({
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
query: {
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
jumpToId: 'some-xblock-id',
},
fragmentIdentifier: 'some-xblock-id',
});
expect(getIFrameUrl({
...props,
srcLanguage: 'test-src-lang',
destLanguage: 'test-dest-lang',
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
test('src and dest languages provided are the same', () => {
const params = stringify({ ...iframeParams, view: props.view });
expect(getIFrameUrl({
...props,
srcLanguage: 'test-lang',
destLanguage: 'test-lang',
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
jumpToId: 'some-xblock-id',
})).toEqual(url);
});
});

View File

@@ -10,14 +10,15 @@ import {
isRtl,
getLocale,
} from '@edx/frontend-platform/i18n';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { useSelector } from 'react-redux';
import { LOADED } from '@src/constants';
import { GetCourseExitNavigation } from '../../course-exit';
import UnitButton from './UnitButton';
import SequenceNavigationTabs from './SequenceNavigationTabs';
import { useSequenceNavigationMetadata } from './hooks';
import { useModel } from '../../../../generic/model-store';
import { LOADED } from '../../../data/slice';
import messages from './messages';
@@ -29,6 +30,11 @@ const SequenceNavigation = ({
onNavigate,
nextHandler,
previousHandler,
nextSequenceHandler,
handleNavigate,
isOpen,
open,
close,
}) => {
const sequence = useModel('sequences', sequenceId);
const {
@@ -95,17 +101,37 @@ const SequenceNavigation = ({
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
return navigationDisabledNextSequence || (
<Button
variant="link"
className="next-btn"
onClick={nextHandler}
disabled={disabled}
iconAfter={nextArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
<PluginSlot
id="next_button_slot"
pluginProps={{
courseId,
disabled,
buttonText,
nextArrow,
nextLink,
shouldDisplayNotificationTriggerInSequence,
sequenceId,
unitId,
nextSequenceHandler,
handleNavigate,
isOpen,
open,
close,
linkComponent: Link,
}}
>
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
</Button>
<Button
variant="link"
className="next-btn"
onClick={nextHandler}
disabled={disabled}
iconAfter={nextArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
>
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
</Button>
</PluginSlot>
);
};
@@ -126,11 +152,21 @@ SequenceNavigation.propTypes = {
onNavigate: PropTypes.func.isRequired,
nextHandler: PropTypes.func.isRequired,
previousHandler: PropTypes.func.isRequired,
close: PropTypes.func,
open: PropTypes.func,
isOpen: PropTypes.bool,
handleNavigate: PropTypes.func,
nextSequenceHandler: PropTypes.func,
};
SequenceNavigation.defaultProps = {
className: null,
unitId: null,
close: null,
open: null,
isOpen: false,
handleNavigate: null,
nextSequenceHandler: null,
};
export default injectIntl(SequenceNavigation);

View File

@@ -86,6 +86,50 @@ describe('Unit Navigation', () => {
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
it('has the "Next" button disabled for entrance exam failed', async () => {
const testCourseMetadata = {
...courseMetadata,
certificate_data: { cert_status: 'bogus_status' },
enrollment: { is_active: true },
entrance_exam_data: {
entrance_exam_current_score: 0, entrance_exam_enabled: true, entrance_exam_id: '1', entrance_exam_minimum_score_pct: 0.65, entrance_exam_passed: false,
},
};
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
// Have to refetch the sequenceId since the new store generates new sequences
const { courseware } = testStore.getState();
const testData = { ...mockData, sequenceId: courseware.sequenceId };
render(
<UnitNavigation {...testData} unitId={unitBlocks[0].id} />,
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
it('has the "Next" button enabled for entrance exam pass', async () => {
const testCourseMetadata = {
...courseMetadata,
certificate_data: { cert_status: 'bogus_status' },
enrollment: { is_active: true },
entrance_exam_data: {
entrance_exam_current_score: 1.0, entrance_exam_enabled: true, entrance_exam_id: '1', entrance_exam_minimum_score_pct: 0.65, entrance_exam_passed: true,
},
};
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
// Have to refetch the sequenceId since the new store generates new sequences
const { courseware } = testStore.getState();
const testData = { ...mockData, sequenceId: courseware.sequenceId };
render(
<UnitNavigation {...testData} unitId={unitBlocks[0].id} />,
{ store: testStore, wrapWithRouter: true },
);
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
});
it('displays end of course message instead of the "Next" button as needed', async () => {
const testCourseMetadata = { ...courseMetadata, certificate_data: { cert_status: 'notpassing' }, enrollment: { is_active: true } };
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);

View File

@@ -13,6 +13,7 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
const sequence = useModel('sequences', currentSequenceId);
const courseId = useSelector(state => state.courseware.courseId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const { entranceExamData: { entranceExamPassed } } = useModel('coursewareMeta', courseId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
// If we don't know the sequence and unit yet, then assume no.
@@ -25,6 +26,16 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
};
}
// if entrance exam is not passed then we should treat this as 1st and last unit
if (entranceExamPassed === false) {
return {
isFirstUnit: true,
isLastUnit: true,
navigationDisabledNextSequence: false,
navigationDisabledPrevSequence: false,
};
}
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);

View File

@@ -28,8 +28,8 @@ const SidebarProvider = ({
const { alwaysOpenAuxiliarySidebar } = useSelector(getCoursewareOutlineSidebarSettings);
const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true';
let initialSidebar = null;
if (isInitiallySidebarOpen && alwaysOpenAuxiliarySidebar) {
let initialSidebar = shouldDisplayFullScreen ? getLocalStorage(`sidebar.${courseId}`) : null;
if (!shouldDisplayFullScreen && isInitiallySidebarOpen && alwaysOpenAuxiliarySidebar) {
initialSidebar = isUnitHasDiscussionTopics
? SIDEBARS[discussionsSidebar.ID].ID
: verifiedMode && SIDEBARS[notificationsSidebar.ID].ID;
@@ -57,7 +57,9 @@ const SidebarProvider = ({
const toggleSidebar = useCallback((sidebarId) => {
// Switch to new sidebar or hide the current sidebar
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
const newSidebar = sidebarId === currentSidebar ? null : sidebarId;
setCurrentSidebar(newSidebar);
setLocalStorage(`sidebar.${courseId}`, newSidebar);
}, [currentSidebar]);
const contextValue = useMemo(() => ({

View File

@@ -42,7 +42,7 @@ const SidebarBase = ({
'd-none': currentSidebar !== sidebarId,
}, className)}
data-testid={`sidebar-${sidebarId}`}
style={{ minWidth: shouldDisplayFullScreen ? '100%' : width }}
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
aria-label={ariaLabel}
id="course-sidebar"
>
@@ -98,7 +98,7 @@ SidebarBase.propTypes = {
};
SidebarBase.defaultProps = {
width: '410px',
width: '31rem',
showTitleBar: true,
};

View File

@@ -9,7 +9,7 @@ import {
} from '@openedx/paragon/icons';
import { useModel } from '@src/generic/model-store';
import { LOADING, LOADED } from '@src/course-home/data/slice';
import { LOADING, LOADED } from '@src/constants';
import PageLoading from '@src/generic/PageLoading';
import {
getSequenceId,

Some files were not shown because too many files have changed in this diff Show More