Compare commits

...

99 Commits

Author SHA1 Message Date
Neo
8d1854dd29 feat: Andal Learning branding - Apply orange #ff4f00 to buttons/navbar/tabs, hide footer
Some checks failed
Lockfile Version check / version-check (push) Has been cancelled
validate / tests (push) Has been cancelled
validate / coverage (push) Has been cancelled
Update Browserslist DB / update-browserslist (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:24:20 +07:00
edX requirements bot
3e8f2ba4e7 chore: update browserslist DB (#1872)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-09 00:54:37 +00:00
renovate[bot]
4af279edeb chore(deps): update dependency postcss-loader to v8.2.1 (#1869)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 21:16:38 +00:00
renovate[bot]
5148ff06d7 chore(deps): update dependency @edx/frontend-platform to v8.5.5 (#1868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 17:33:54 +00:00
edX requirements bot
d0544bc4e2 chore: update browserslist DB (#1867)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-02 00:53:15 +00:00
edX requirements bot
ceed6cb287 chore: update browserslist DB (#1866)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-23 21:09:20 +00:00
edX requirements bot
004315fdc4 chore: update browserslist DB (#1865)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-16 01:16:37 +00:00
renovate[bot]
8baaa1d50c fix(deps): update dependency @edx/browserslist-config to v1.5.1 (#1861)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 22:43:27 +00:00
renovate[bot]
0bd9483cdb chore(deps): update dependency sass-loader to v16.0.7 (#1859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 22:36:17 +00:00
Brian Smith
13eea81fe7 fix(deps): regenerate package-lock.json (#1855)
* fix(deps): regenerate package-lock.json

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(deps): regenerate package-lock.json

Moved @openedx/frontend-build from dependencies to devDependencies.
Removed direct jest devDependency which was causing ts-jest hoisting issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): use require() for MockedPluginSlot in jest.mock

Jest hoists jest.mock() calls to the top of the file, which caused
MockedPluginSlot to be undefined when the mock factory executed.
Using require() inside the factory ensures it loads at runtime.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(types): handle nullable breakpoint types

Paragon's breakpoint types now have optional minWidth/maxWidth properties.
Added non-null assertions since these values are always defined in practice.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): add IntlProvider to ContentIFrame tests

Paragon's ModalDialog now uses useIntl() (openedx/paragon#3624),
requiring an IntlProvider in the component ancestry.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): await async operations in Course tests

Fixed dangling waitFor blocks that weren't awaited, causing tests
to not actually wait for async operations. Changed to properly use
await with screen.findBy*() queries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): use dynamic imports in LearnerToolsSlot tests

Jest hoists mock calls but ES imports run before the test body.
Using dynamic imports in beforeEach ensures mocks are set up
before modules are loaded.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-02-13 17:29:07 -05:00
renovate[bot]
1d0ab113e7 chore(deps): update dependency lodash to v4.17.23 [security] (#1857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 01:00:09 +00:00
edX requirements bot
e34382aa11 chore: update browserslist DB (#1848)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-09 00:54:14 +00:00
edX requirements bot
bf127d5292 chore: update browserslist DB (#1847)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-01-12 00:46:48 +00:00
edX requirements bot
522ebb0003 chore: update browserslist DB (#1843)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-01-05 00:47:11 +00:00
Michael Roytman
630e843816 feat: fetch exams data on the progress page (#1829)
* feat: fetch exams data on the progress page

This commit adds changes to fetch the exams data associated with all subsections relevant to the progress page. Exams data is relevant to the progress page because the status of a learner's exam attempt may influence the state of their grade.

This allows children of the root ProgressPage or downstream plugin slots to access this data from the Redux store.

---------

Co-authored-by: nsprenkle <nsprenkle@2u.com>
2025-12-15 15:18:22 -05:00
edX requirements bot
9c5ac6ac5b chore: update browserslist DB (#1839)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-12-15 00:45:08 +00:00
edX requirements bot
f43ac7bcc3 chore: update browserslist DB (#1830)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-12-01 00:49:30 +00:00
Maniraja Raman
b282bc05df feat: update chat component to use PluginSlot and simplify logic (#1810) 2025-11-18 22:19:30 -05:00
edX requirements bot
d987aed861 chore: update browserslist DB (#1826)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-17 00:41:45 +00:00
Muhammad Labeeb
9ece337504 fix: remove proctortrack references (#1825)
Update all descriptions mentioning proctortrack with a generic message.

  https://github.com/openedx/edx-platform/issues/36329
2025-11-13 12:17:03 -05:00
edX requirements bot
d3235af879 chore: update browserslist DB (#1822)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-10 00:42:11 +00:00
kshitij.sobti
d0a8778015 feat: Add slots to add tab links for courses
Adds new slot that allow adding new links to course tabs.
2025-11-03 16:40:08 +05:30
edX requirements bot
f8381e7900 chore: update browserslist DB (#1818)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-03 00:42:13 +00:00
Jansen Kantor
1d5484ff1d fix: re-add removed import (#1815) 2025-10-28 11:12:33 -04:00
Muhammad Anas
52692dc662 refactor: shift grade summary calculation to backend and display "hidden grades" label in the grade table (#1797)
Refactors the grade summary logic to delegate all calculation responsibilities to the backend.
Previously, the frontend was performing grade summary computations using data fetched from the API. Now, the API itself provides the fully computed grade summary, simplifying the frontend and ensuring consistent results across clients.

Additionally, a "Hidden Grades" label has been added in the grade summary table to clearly indicate sections where grades are not visible to learners.

Finally, for visibility settings that depend on the due date, this PR adds a banner on the Progress page indicating that grades are not yet released, along with the relevant due date information.
2025-10-24 14:55:12 -03:00
Jansen Kantor
f91af211f6 feat: add plugin slot for content iframe error component (#1771)
* feat: add plugin slot for content iframe error component

* style: quality

* fix: copilot suggestions
2025-10-20 12:03:07 -04:00
edX requirements bot
7318fb3ef7 chore: update browserslist DB (#1808)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-10-20 00:41:43 +00:00
Michael Roytman
7233f08d3d feat: update version of frontend-lib-learning-assistant to 2.23.1 (#1807)
This commit installs version 2.23.1 of @edx/frontend-lib-learning-assistant.

This release fixes a bug where the Xpert Learning Assistant was only available to learners in the audit and credit modes.

See https://github.com/edx/frontend-lib-learning-assistant/releases/tag/v2.23.1.
2025-10-15 13:35:20 -04:00
edX requirements bot
d6d229f1c3 chore: update browserslist DB (#1799)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-10-13 00:40:36 +00:00
Muhammad Anas
47b9a436a6 chore: bump frontend-component-header to v8.x.x (#1791)
* chore: bump frontend-component-header to v6.6.x

* chore: bump frontend-component-header to ^8.0.0
2025-10-08 09:48:07 -04:00
renovate[bot]
e556d5b74c chore(deps): update dependency @edx/frontend-component-header to v6.4.2 (#1804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 19:17:29 +00:00
renovate[bot]
694d95a816 chore(deps): update dependency @edx/frontend-component-footer to v14.9.2 (#1803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 16:13:33 +00:00
PKulkoRaccoonGang
e83813da8e build: Upgrade to node 24 and update package-lock.json 2025-10-06 11:54:57 -04:00
edX requirements bot
a54a1b8c3c chore: update browserslist DB (#1795)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-29 00:39:45 +00:00
Feanil Patel
d3188efbcc build: remove unused @edx/reactifex package
Remove @edx/reactifex package from devDependencies as it is no longer
needed. Translation extraction functionality has been verified to work
correctly without this dependency.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 13:09:24 -04:00
edX requirements bot
33f737579a chore: update browserslist DB (#1793)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-22 00:41:28 +00:00
Agrendalath
870263001e fix: ensure iframe visibility tracking is triggered on load
The previous implementation had a race condition that sometimes prevented
XBlocks from being marked as viewed. Users had to scroll or resize the window
to trigger visibility tracking instead of having it happen once content loads.
2025-09-18 16:49:07 +05:30
Peter Kulko
af50d5a6ed test: Add Node 24 to CI matrix (#1752) 2025-09-16 10:55:28 -04:00
Samuel Allan
7fccf7794c fix: update frontend-build to fix install issues
Earlier versions of @openedx/frontend-build used on older version of
'sharp', which caused intermittent installation issues. The version of
'sharp' was updated in @openedx/frontend-build to fix these issues, so
the frontend-build version can be updated here, to fix the issues in
this project too. See
https://github.com/openedx/frontend-build/issues/664 and
https://github.com/openedx/frontend-build/pull/665 for more information.

The frontend-build dependency was updated by:

```
npm install --package-lock-only @openedx/frontend-build
```

Private-ref: https://tasks.opencraft.com/browse/BB-9953
2025-09-12 14:21:20 -03:00
edX requirements bot
c760bc479b chore: update browserslist DB (#1788)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-08 00:40:25 +00:00
edX requirements bot
d5140a6bf0 chore: update browserslist DB (#1784)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-01 00:46:51 +00:00
Isaac Lee
9bf5d01c41 chore: update learning assistant plugin 2.23.0 (#1781) 2025-08-25 12:54:13 -04:00
edX requirements bot
f3334085d7 chore: update browserslist DB (#1783)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-08-25 00:41:09 +00:00
dependabot[bot]
4840fff44b chore(deps): bump actions/download-artifact from 4 to 5 (#1779)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-21 14:21:56 -07:00
Muhammad Adeel Tajamul
579bd0365b feat: updated notification preferences unsubscribe flow (#1778) 2025-08-21 14:09:28 -04:00
abdullahwaheed
2b4a9661a5 chore: update browserslist DB 2025-08-18 06:14:24 +05:30
dependabot[bot]
a6e4e28e58 chore(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-13 14:33:52 -04:00
dependabot[bot]
e6f7588ccd chore(deps): bump js-toml from 1.0.1 to 1.0.2
Bumps [js-toml](https://github.com/sunnyadn/js-toml) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/sunnyadn/js-toml/releases)
- [Commits](https://github.com/sunnyadn/js-toml/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: js-toml
  dependency-version: 1.0.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-06 16:44:33 +05:30
Muhammad Anas
db29e314c3 feat: customize proctoring review requirements link using externalLinkUrlOverrides (#1775) 2025-08-04 10:36:44 -04:00
Diana Olarte
e9121f9261 feat: display the certificate available date if available in progress tab 2025-08-01 16:21:36 +05:30
Nawfal Ahmed
9cbc2276d6 feat: use discount info endpoint for streak discount information (#1763)
* feat: use discount info endpoint for streak discount information

* feat: pass course run key to discount code info call

* feat: move changes behind a flag

* fix: use async IIFE inside useEffect

* fix: fix line length

* fix: remove default value in dev

* fix: improve coverage by adding conditional test based on env value

* refactor: move logic inside function

* refactor: move functions to utils

* fix: ignore merge config
2025-07-31 14:53:18 -04:00
Maniraja Raman
4c8aa7c80c feat: add FEATURE_ENABLE_CHAT_V2_ENDPOINT to environment files and update library version (#1768) 2025-07-29 16:48:07 -04:00
Diana Villalvazo
68926334a1 test: transform snapshot into rtl test 1/2 (#1756)
* test: remove snapshots and use rtl tests

* test: improve coverage on search results test
2025-07-29 10:13:07 -04:00
Diana Villalvazo
56a73eee15 test: transform snapshot into rtl test 2/2 (#1757)
* test: remove snapshots and use rtl tests

* test: add expected result on map search
2025-07-29 10:03:07 -04:00
Jacobo Dominguez
bf95916063 docs: add comprehensive readme documentation for plugin slots (#1770) 2025-07-28 14:10:49 -04:00
Diana Villalvazo
48270c35dd chore: remove react-unit-test-utils package (#1758) 2025-07-28 14:02:55 -04:00
Diana Villalvazo
33d7d669d9 test: deprecate react-unit-test-utils 1/2 (#1750) 2025-07-28 12:48:53 -04:00
Diana Villalvazo
a75c89cd14 test: deprecate react-test-utils 2/2 (#1751) 2025-07-28 12:12:32 -04:00
Ihor Romaniuk
06902d8ae8 feat: remove waffle flags for managing course outline sidebar (#1713)
* feat: remove waffle flags for managing course outline sidebar

* fix: flag and tests

* fix: product-tours tests

* fix: remove default content for SequenceNavigationSlot and update tests

* fix: remove default content for CourseBreadcrumbsSlot

* fix: update plugin-slots version and documentation

* revert: update plugin-slots version

* fix: update tests
2025-07-21 14:57:43 -04:00
edX requirements bot
e4134641e6 chore: update browserslist DB (#1755)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-07-07 00:44:46 +00:00
edX requirements bot
77fcc83efd chore: update browserslist DB (#1744)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-06-30 00:43:50 +00:00
Farhaan Bukhsh
b0505352be fix: Fixes the auto_advance feature for video XBlock
The commit adds eventlistener which picks up the autoAdvance message and
triggers the next sequence. This has the same effect of clicking the
next button.

Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
2025-06-24 19:22:33 +05:30
Brian Smith
ddbc2124ef feat!: add design tokens support (#1737)
BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
2025-06-18 12:07:01 -04:00
renovate[bot]
462e75f6a6 fix(deps): update dependency @edx/frontend-platform to v8.4.0 (#1739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 14:03:53 +00:00
renovate[bot]
bc4c8c2dec fix(deps): update dependency @edx/frontend-component-footer to v14.9.0 (#1736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 05:29:53 +00:00
renovate[bot]
ecd5164806 fix(deps): update dependency @openedx/frontend-build to v14.6.1 (#1700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 01:03:51 +00:00
edX requirements bot
44d952bef7 chore: update browserslist DB (#1734)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-06-16 00:42:49 +00:00
sundasnoreen12
7eddc918bb fix: fixed right panel closing issue (#1732)
* fix: fixed right panel closing issue

* fix: fixed status of notificationTrayStatus in session storage

* test: added test cases to close or open notification tray
2025-06-13 10:33:14 -04:00
edX requirements bot
f28528e813 chore: update browserslist DB (#1730)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-06-09 00:43:43 +00:00
wgu-jesse-stewart
ab3f5fd7bc fix: ensure full-height layout (#1724)
When content within a sequence was shorter than the height of the browser viewport, the `.sequence-container > .outline-sidebar-wrapper` element does not expand appropriately.  This caused the course outline sidebar to be partially or completely hidden from view.
2025-06-06 15:55:15 -03:00
ayesha waris
73eaf61261 revert: "temp: reverse stack order of discussions and upsell in sidebar (#1705)" (#1712)
This reverts commit 2ce833341b.
2025-06-05 13:25:22 -04:00
KristinAoki
db9663b664 feat: add start:with-theme command 2025-06-05 19:47:23 +05:30
jacobo-dominguez-wgu
7edac93752 fix: removing '-1 +' from media queries (#1727) 2025-06-04 16:27:04 -07:00
Javier Ontiveros
d1dede568e feat: hide sidebar on screen resize (#1720)
Adds an event handler on the window resize to check if the sidebar isOpen and the size of the viewport is smaller than the sidebar display to hide the sidebar and prevent it from blocking the course view.
2025-06-04 16:22:01 -03:00
Javier Ontiveros
31b02d777f feat: disable completion item icons on flag (#1714)
* feat: base modifications to disable completion checks when flag enabled

* chore: started updating tests

* chore: udpated tests

* chore: added missing negative test

---------

Co-authored-by: Adolfo R. Brandes <adolfo@axim.org>
2025-06-04 15:33:12 -03:00
dependabot[bot]
67bb54a028 chore(deps): bump tar-fs
Bumps  and [tar-fs](https://github.com/mafintosh/tar-fs). These dependencies needed to be updated together.

Updates `tar-fs` from 2.1.2 to 3.0.9
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.2...v3.0.9)

Updates `tar-fs` from 3.0.8 to 3.0.9
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.2...v3.0.9)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 3.0.9
  dependency-type: indirect
- dependency-name: tar-fs
  dependency-version: 3.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-04 20:32:27 +05:30
jacobo-dominguez-wgu
847d4e5ce6 fix: center and align previous and next buttons (#1718)
* fix: prev and next buttons were not propertly centered and aligned

* fix: removing flex-basis property for navigation buttons
2025-06-04 10:22:45 -03:00
edX requirements bot
b89cdb4a69 chore: update browserslist DB (#1723)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-06-02 00:42:51 +00:00
Maria Grimaldi
a1d0afff6c refactor: move file to corresponding folder 2025-05-30 08:33:47 -04:00
Maria Grimaldi
1714f285b0 chore: add workflow to pull release testing issues into the BTR board
Add GH workflow that includes issues into the BTR board after the issue
is labeled with `release testing`.  Also add label needs triage for
bug triaging issues.
2025-05-30 08:18:57 -04:00
Jorg Are
03cda5326a chore: add id to verified-upgrade-deadline link (#1719) 2025-05-29 15:33:51 -04:00
Ihor Romaniuk
a71152b008 feat: move sequence navigation to plugin slot (#1716) 2025-05-29 12:05:00 -04:00
wgu-jesse-stewart
d14c2a9ffd fix: sidebar not showing sections on pending courses (#1679) 2025-05-27 13:47:21 -04:00
Jorg Are
b6c29df0a0 chore: add identifiers to some upgrade links/buttons (#1686) 2025-05-21 08:45:19 -04:00
ayesha waris
2ce833341b temp: reverse stack order of discussions and upsell in sidebar (#1705)
Co-authored-by: Ayesha Waris <ayesha.waris@192.168.10.27>
2025-05-19 11:45:32 -04:00
renovate[bot]
ff57a6b217 fix(deps): update dependency @edx/frontend-platform to v8.3.7 (#1710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 13:58:38 +00:00
edX requirements bot
dc6ee749be chore: update browserslist DB (#1709)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-05-19 00:42:52 +00:00
renovate[bot]
236fb57023 fix(deps): update dependency @edx/frontend-platform to v8.3.6 (#1707)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-15 16:38:56 +00:00
Nathan Sprenkle
d3d2f75c12 chore: re-add query-string dependency (#1703)
Several of our plugins still rely on this, though it should be removed ASAP
2025-05-12 10:36:48 -04:00
edX requirements bot
8e9306d35a chore: update browserslist DB (#1704)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-05-12 00:42:14 +00:00
Jansen Kantor
b1ee8a3713 feat: add course end dashboard plugin slots (#1658)
* feat: add additional course end plugin slots

* fix: bring plugin slot names in line with new naming scheme

* refactor: change plugin files to tsx,remove propTypes

* fixup! refactor: change plugin files to tsx,remove propTypes

* fixup! fixup! refactor: change plugin files to tsx,remove propTypes

* fixup! fixup! fixup! refactor: change plugin files to tsx,remove propTypes

* fix: accidentally committed test code

* fix: plugin-slot fixes

* chore: add ENTERPRISE_LEARNER_PORTAL_URL env var
2025-05-08 14:23:41 -04:00
renovate[bot]
73406fbb31 fix(deps): update dependency @edx/openedx-atlas to ^0.7.0 (#1699)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 02:39:17 +00:00
renovate[bot]
f4ae1c51ff fix(deps): update dependency @edx/frontend-component-footer to v14.7.1 (#1698)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 22:27:28 +00:00
renovate[bot]
7ef3892027 fix(deps): update dependency @edx/frontend-platform to v8.3.5 (#1697)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 17:04:09 +00:00
Braden MacDonald
1484bc50f7 refactor: replace query-string pkg, remove unused <ShareButton> (#1676) 2025-05-05 09:56:09 -07:00
edX requirements bot
6b197aad27 chore: update browserslist DB (#1696)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-05-05 00:42:17 +00:00
Maxim Beder
1412bfe209 feat: update certificate icons
Old certificates icons contained edX trademark logos, which were not
suitable for the open source repos. Replaced with icons that contain
Open edX logos.
2025-04-30 10:27:27 -03:00
Brian Smith
e8d3bd7c24 fix(docs): correct ProgressCertificateStatusSlot README title (#1689) 2025-04-25 13:37:58 -04:00
Brian Smith
511091055b fix(docs): correct CourseRecommendationsSlot README title (#1688) 2025-04-25 13:37:02 -04:00
181 changed files with 8038 additions and 7329 deletions

4
.env
View File

@@ -12,10 +12,12 @@ CREDIT_HELP_LINK_URL=''
CSRF_TOKEN_API_PATH='' CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL='' DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL='' DISCUSSIONS_MFE_BASE_URL=''
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='' ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='' ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
ENTERPRISE_LEARNER_PORTAL_URL=''
EXAMS_BASE_URL='' EXAMS_BASE_URL=''
FAVICON_URL='' FAVICON_URL=''
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
@@ -49,3 +51,5 @@ TWITTER_URL=''
USER_INFO_COOKIE_NAME='' USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY='' OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -12,10 +12,12 @@ CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-co
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130' ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
EXAMS_BASE_URL='' EXAMS_BASE_URL=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
@@ -51,3 +53,5 @@ CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy' PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY='' OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -12,10 +12,12 @@ CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-co
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130' ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
EXAMS_BASE_URL='http://localhost:18740' EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
@@ -48,3 +50,5 @@ TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy' PRIVACY_POLICY_URL='http://localhost:18000/privacy'
SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:Enterprise'
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'

View File

@@ -0,0 +1,18 @@
# Run the workflow that adds new tickets that are labelled "release testing"
# to the org-wide BTR project board
name: Add release testing issues to the BTR project board
on:
issues:
types: [labeled]
# This workflow is triggered when an issue is labeled with 'release testing'.
# It adds the issue to the BTR project and applies the 'needs triage' label
# if it doesn't already have it.
jobs:
handle-release-testing:
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}

View File

@@ -10,7 +10,7 @@ jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -24,11 +24,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: tests needs: tests
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Download code coverage results - name: Download code coverage results
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
name: code-coverage-report pattern: code-coverage-report
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:

2
.nvmrc
View File

@@ -1 +1 @@
20 24

6608
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,10 +15,10 @@
"i18n_extract": "fedx-scripts formatjs extract", "i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .", "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 .", "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress", "start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"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", "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", "test": "NODE_ENV=test fedx-scripts jest --coverage --passWithNoTests",
"test:watch": "fedx-scripts jest --watch --passWithNoTests", "test:watch": "fedx-scripts jest --watch --passWithNoTests",
"types": "tsc --noEmit" "types": "tsc --noEmit"
}, },
@@ -33,21 +33,19 @@
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/browserslist-config": "1.5.0", "@edx/browserslist-config": "1.5.1",
"@edx/frontend-component-footer": "^14.6.0", "@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0", "@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-lib-learning-assistant": "^2.20.0", "@edx/frontend-lib-learning-assistant": "^2.24.0",
"@edx/frontend-lib-special-exams": "^3.5.0", "@edx/frontend-lib-special-exams": "^4.0.0",
"@edx/frontend-platform": "^8.3.1", "@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.6.0", "@edx/openedx-atlas": "^0.7.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.1.4", "@fortawesome/react-fontawesome": "^0.1.4",
"@openedx/frontend-build": "^14.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0", "@openedx/paragon": "^23.4.5",
"@popperjs/core": "2.11.8", "@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.9.7", "@reduxjs/toolkit": "1.9.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",
@@ -75,7 +73,7 @@
"truncate-html": "1.0.4" "truncate-html": "1.0.4"
}, },
"devDependencies": { "devDependencies": {
"@edx/reactifex": "2.2.0", "@openedx/frontend-build": "^14.6.2",
"@pact-foundation/pact": "^13.0.0", "@pact-foundation/pact": "^13.0.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
@@ -83,7 +81,6 @@
"axios-mock-adapter": "2.1.0", "axios-mock-adapter": "2.1.0",
"bundlewatch": "^0.4.0", "bundlewatch": "^0.4.0",
"eslint-import-resolver-webpack": "^0.13.9", "eslint-import-resolver-webpack": "^0.13.9",
"jest": "^29.7.0",
"jest-console-group-reporter": "^1.1.1", "jest-console-group-reporter": "^1.1.1",
"jest-when": "^3.6.0", "jest-when": "^3.6.0",
"rosie": "2.1.1" "rosie": "2.1.1"

View File

@@ -1,224 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<React Strict Mode>
<ErrorPage
message="test-error-message"
/>
</React Strict Mode>
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<React Strict Mode>
<AppProvider
store={
{
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
>
<link
href="favicon-url"
rel="shortcut icon"
type="image/x-icon"
/>
</HelmetWrapper>
<PathFixesProvider>
<NoticesProvider>
<UserMessagesProvider>
<Routes>
<Route
element={
<PageWrap>
<Page Not Found />
</PageWrap>
}
path="*"
/>
<Route
element={
<PageWrap>
<Goal Unsubscribe />
</PageWrap>
}
path="/goal-unsubscribe/:token"
/>
<Route
element={
<PageWrap>
<Courseware Redirect Landing Page />
</PageWrap>
}
path="/redirect/*"
/>
<Route
element={
<PageWrap>
<Preferences Unsubscribe />
</PageWrap>
}
path="/preferences-unsubscribe/:userToken/:updatePatch"
/>
<Route
element={
<DecodePageRoute>
<Course Access Error Page />
</DecodePageRoute>
}
path="/course/:courseId/access-denied"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseHome"
tab="outline"
>
<Outline Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/home"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseHome"
tab="lti_live"
>
<Live Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/live"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseHome"
tab="dates"
>
<Dates Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/dates"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseHome"
tab="discussion"
>
<Discussion Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/discussion/:path/*"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
isProgressTab={true}
slice="courseHome"
tab="progress"
>
<Progress Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/progress/:targetUserId/"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
isProgressTab={true}
slice="courseHome"
tab="progress"
>
<Progress Tab />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/progress"
/>
<Route
element={
<DecodePageRoute>
<Tab Container
fetch={[Function]}
slice="courseware"
tab="courseware"
>
<Course Exit />
</Tab Container>
</DecodePageRoute>
}
path="/course/:courseId/course-end"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/course/:courseId/:sequenceId/:unitId"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/course/:courseId/:sequenceId"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/course/:courseId"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/preview/course/:courseId/:sequenceId/:unitId"
/>
<Route
element={
<DecodePageRoute>
<Courseware Container />
</DecodePageRoute>
}
path="/preview/course/:courseId/:sequenceId"
/>
</Routes>
</UserMessagesProvider>
</NoticesProvider>
</PathFixesProvider>
</AppProvider>
</React Strict Mode>
`;

View File

@@ -22,7 +22,7 @@ export const DECODE_ROUTES = {
export const ROUTES = { export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token', UNSUBSCRIBE: '/goal-unsubscribe/:token',
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch', PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch?',
REDIRECT: '/redirect/*', REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard', DASHBOARD: 'dashboard',
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard', ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',

View File

@@ -5,6 +5,7 @@ import {
screen, screen,
} from '../../setupTest'; } from '../../setupTest';
import CoursewareSearchEmpty from './CoursewareSearchEmpty'; import CoursewareSearchEmpty from './CoursewareSearchEmpty';
import messages from './messages';
function renderComponent() { function renderComponent() {
const { container } = render(<CoursewareSearchEmpty />); const { container } = render(<CoursewareSearchEmpty />);
@@ -16,9 +17,12 @@ describe('CoursewareSearchEmpty', () => {
initializeMockApp(); initializeMockApp();
}); });
it('should match the snapshot', () => { it('render empty results text and corresponding classes', () => {
renderComponent(); renderComponent();
const emptyText = screen.getByText(messages.searchResultsNone.defaultMessage);
expect(screen.getByTestId('no-results')).toMatchSnapshot(); expect(emptyText).toBeInTheDocument();
expect(emptyText).toHaveClass('courseware-search-results__empty');
expect(emptyText).toHaveAttribute('data-testid', 'no-results');
expect(emptyText.parentElement).toHaveClass('courseware-search-results');
}); });
}); });

View File

@@ -7,6 +7,7 @@ import {
import CoursewareSearchResults from './CoursewareSearchResults'; import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages'; import messages from './messages';
import searchResultsFactory from './test-data/search-results-factory'; import searchResultsFactory from './test-data/search-results-factory';
import * as mock from './test-data/mocked-response.json';
jest.mock('react-redux'); jest.mock('react-redux');
@@ -34,8 +35,53 @@ describe('CoursewareSearchResults', () => {
renderComponent({ results }); renderComponent({ results });
}); });
it('should match the snapshot', () => { it('should render complete list', () => {
expect(screen.getByTestId('search-results')).toMatchSnapshot(); const courses = screen.getAllByRole('link');
expect(courses.length).toBe(mock.results.length);
});
it('should render correct link for internal course', () => {
const courses = screen.getAllByRole('link');
const firstCourse = courses[0];
const firstCourseTitle = firstCourse.querySelector('.courseware-search-results__title span');
expect(firstCourseTitle.innerHTML).toEqual(mock.results[0].data.content.display_name);
expect(firstCourse.href).toContain(mock.results[0].data.url);
expect(firstCourse).not.toHaveAttribute('target', '_blank');
expect(firstCourse).not.toHaveAttribute('rel', 'nofollow');
});
it('should render correct link if is External url course', () => {
const courses = screen.getAllByRole('link');
const externalCourse = courses[courses.length - 1];
const externalCourseTitle = externalCourse.querySelector('.courseware-search-results__title span');
expect(externalCourseTitle.innerHTML).toEqual(mock.results[mock.results.length - 1].data.content.display_name);
expect(externalCourse.href).toContain(mock.results[mock.results.length - 1].data.url);
expect(externalCourse).toHaveAttribute('target', '_blank');
expect(externalCourse).toHaveAttribute('rel', 'nofollow');
const icon = externalCourse.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should render location breadcrumbs', () => {
const breadcrumbs = screen.getAllByText(mock.results[0].data.location[0]);
expect(breadcrumbs.length).toBeGreaterThan(0);
const firstBreadcrumb = breadcrumbs[0].closest('li');
expect(firstBreadcrumb).toBeInTheDocument();
expect(firstBreadcrumb.querySelector('div').textContent).toBe(mock.results[0].data.location[0]);
expect(firstBreadcrumb.nextSibling.querySelector('div').textContent).toBe(mock.results[0].data.location[1]);
});
});
describe('when results are provided with content hits', () => {
beforeEach(() => {
const { results } = searchResultsFactory('Passing');
renderComponent({ results });
});
it('should render content hits', () => {
const contentHits = screen.getByText('1');
expect(contentHits).toBeInTheDocument();
expect(contentHits.tagName).toBe('EM');
}); });
}); });
}); });

View File

@@ -1,10 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursewareSearchEmpty should match the snapshot 1`] = `
<p
class="courseware-search-results__empty"
data-testid="no-results"
>
No results found.
</p>
`;

View File

@@ -1,1238 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoursewareSearchResults when list of results is provided should match the snapshot 1`] = `
<div
class="courseware-search-results"
data-testid="search-results"
>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 4H2v16h20V6H12l-2-2z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Demo Course Overview
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Passing a Course
</span>
<em>
1
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Passing a Course
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 4H2v16h20V6H12l-2-2z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Passing a Course
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Passing a Course
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Text Input
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Text input
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Pointing on a Picture
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Pointing on a Picture
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Getting Answers
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Getting Answers
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 10.5V6H3v12h14v-4.5l4 4v-11l-4 4Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Welcome!
</span>
<em>
30
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
<li>
<div>
Introduction: Video and Sequences
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Multiple Choice Questions
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Multiple Choice Questions
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Numerical Input
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Homework - Question Styles
</div>
</li>
<li>
<div>
Numerical Input
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 10.5V6H3v12h14v-4.5l4 4v-11l-4 4Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Connecting a Circuit and a Circuit Diagram
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
Video Presentation Styles
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
CAPA
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 2: Get Interactive
</div>
</li>
<li>
<div>
Homework - Labs and Demos
</div>
</li>
<li>
<div>
Code Grader
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Interactive Questions
</span>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
Interactive Questions
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Blank HTML Page
</span>
<em>
6
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Introduction
</div>
</li>
<li>
<div>
Demo Course Overview
</div>
</li>
<li>
<div>
Introduction: Video and Sequences
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Discussion Forums
</span>
<em>
5
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Discussion Forums
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Overall Grade
</span>
<em>
7
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
Overall Grade Performance
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Blank HTML Page
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Find Your Study Buddy
</span>
<em>
3
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
<li>
<div>
Homework - Find Your Study Buddy
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
Be Social
</span>
<em>
4
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 3: Be Social
</div>
</li>
<li>
<div>
Lesson 3 - Be Social
</div>
</li>
<li>
<div>
Be Social
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
EdX Exams
</span>
<em>
4
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
About Exams and Certificates
</div>
</li>
<li>
<div>
edX Exams
</div>
</li>
<li>
<div>
EdX Exams
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
When Are Your Exams?
</span>
<em>
2
</em>
</div>
<ul
class="courseware-search-results__breadcrumbs"
>
<li>
<div>
Example Week 1: Getting Started
</div>
</li>
<li>
<div>
Lesson 1 - Getting Started
</div>
</li>
<li>
<div>
When Are Your Exams?
</div>
</li>
</ul>
</div>
</a>
<a
class="courseware-search-results__item"
href="https://www.edx.org"
rel="nofollow"
target="_blank"
>
<div
class="courseware-search-results__icon"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3v18h18V3H3Zm11 14H7v-2h7v2Zm3-4H7v-2h10v2Zm0-4H7V7h10v2Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<div
class="courseware-search-results__info"
>
<div
class="courseware-search-results__title"
>
<span>
External Course Link Test
</span>
</div>
</div>
</a>
</div>
`;

View File

@@ -1,306 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`mapSearchResponse when the response is correct should match snapshot 1`] = `
{
"filters": [
{
"count": 7,
"key": "capa",
"label": "CAPA",
},
{
"count": 2,
"key": "sequence",
"label": "Sequence",
},
{
"count": 9,
"key": "text",
"label": "Text",
},
{
"count": 1,
"key": "unknown",
"label": "Unknown",
},
{
"count": 2,
"key": "video",
"label": "Video",
},
],
"maxScore": 3.4545178,
"ms": 5,
"results": [
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
"location": [
"Introduction",
"Demo Course Overview",
],
"score": 3.4545178,
"title": "Demo Course Overview",
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
"location": [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
],
"score": 3.4545178,
"title": "Passing a Course",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
"location": [
"About Exams and Certificates",
"edX Exams",
"Passing a Course",
],
"score": 3.4545178,
"title": "Passing a Course",
"type": "sequence",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Text input",
],
"score": 1.5874016,
"title": "Text Input",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Pointing on a Picture",
],
"score": 1.5499392,
"title": "Pointing on a Picture",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
"location": [
"About Exams and Certificates",
"edX Exams",
"Getting Answers",
],
"score": 1.5003732,
"title": "Getting Answers",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
"location": [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
],
"score": 1.4792063,
"title": "Welcome!",
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Multiple Choice Questions",
],
"score": 1.4341705,
"title": "Multiple Choice Questions",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
"location": [
"Example Week 1: Getting Started",
"Homework - Question Styles",
"Numerical Input",
],
"score": 1.2987298,
"title": "Numerical Input",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Video Presentation Styles",
],
"score": 1.1870136,
"title": "Connecting a Circuit and a Circuit Diagram",
"type": "video",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
"location": [
"Example Week 2: Get Interactive",
"Homework - Labs and Demos",
"Code Grader",
],
"score": 1.0107487,
"title": "CAPA",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"Interactive Questions",
],
"score": 0.96387196,
"title": "Interactive Questions",
"type": "capa",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
"location": [
"Introduction",
"Demo Course Overview",
"Introduction: Video and Sequences",
],
"score": 0.8844358,
"title": "Blank HTML Page",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Discussion Forums",
],
"score": 0.8803684,
"title": "Discussion Forums",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
"location": [
"About Exams and Certificates",
"edX Exams",
"Overall Grade Performance",
],
"score": 0.87981963,
"title": "Overall Grade",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Homework - Find Your Study Buddy",
],
"score": 0.84284115,
"title": "Blank HTML Page",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
"location": [
"Example Week 3: Be Social",
"Homework - Find Your Study Buddy",
"Homework - Find Your Study Buddy",
],
"score": 0.84284115,
"title": "Find Your Study Buddy",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
"location": [
"Example Week 3: Be Social",
"Lesson 3 - Be Social",
"Be Social",
],
"score": 0.84210813,
"title": "Be Social",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
"location": [
"About Exams and Certificates",
"edX Exams",
"EdX Exams",
],
"score": 0.8306555,
"title": "EdX Exams",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
},
{
"contentHits": 0,
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
"location": [
"Example Week 1: Getting Started",
"Lesson 1 - Getting Started",
"When Are Your Exams? ",
],
"score": 0.82610154,
"title": "When Are Your Exams? ",
"type": "text",
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
},
{
"contentHits": 0,
"id": "random-element-id",
"location": null,
"score": 0.82610154,
"title": "External Course Link Test",
"type": "unknown",
"url": "https://www.edx.org",
},
],
"total": 29,
}
`;

View File

@@ -9,8 +9,8 @@
height: 100%; height: 100%;
max-width: none; max-width: none;
margin: 0; margin: 0;
border-top: 1px solid $light-300; border-top: 1px solid var(--pgn-color-light-300);
z-index: $zindex-modal; // Bootstrap's z-index layer for Modals. z-index: var(--pgn-elevation-modal-zindex); // Bootstrap's z-index layer for Modals.
&__form { &__form {
position: relative; position: relative;
@@ -47,7 +47,7 @@
&__results-summary { &__results-summary {
font-size: .9rem; font-size: .9rem;
color: $gray-500; color: var(--pgn-color-gray-500);
padding: 1rem 0 .5rem; padding: 1rem 0 .5rem;
} }
@@ -62,7 +62,7 @@
margin-top: 1.5rem; margin-top: 1.5rem;
&__empty { &__empty {
color: $gray-500; color: var(--pgn-color-gray-500);
padding: 6rem 0; padding: 6rem 0;
text-align: center; text-align: center;
} }
@@ -76,17 +76,17 @@
&:hover { &:hover {
text-decoration: none; text-decoration: none;
background: $light-300; background: var(--pgn-color-light-300);
} }
&:not(:first-child) { &:not(:first-child) {
border-top: 1px solid $light-300; border-top: 1px solid var(--pgn-color-light-300);
} }
} }
&__icon { &__icon {
padding: 0.375rem 0 0 0.375rem; padding: 0.375rem 0 0 0.375rem;
color: $gray-300; color: var(--pgn-color-gray-300);
} }
&__info { &__info {
@@ -99,7 +99,7 @@
align-items: center; align-items: center;
line-height: 2.5; line-height: 2.5;
font-size: 0.875rem; font-size: 0.875rem;
color: $black; color: var(--pgn-color-black);
> span { > span {
display: block; display: block;
@@ -113,7 +113,7 @@
font-variant-numeric: lining-nums tabular-nums; font-variant-numeric: lining-nums tabular-nums;
min-width: 1.25rem; min-width: 1.25rem;
line-height: 1rem; line-height: 1rem;
background: $light-300; background: var(--pgn-color-light-300);
border-radius: 99rem; border-radius: 99rem;
font-style: normal; font-style: normal;
margin-left: 0.375rem; margin-left: 0.375rem;
@@ -125,7 +125,7 @@
&__breadcrumbs { &__breadcrumbs {
display: flex; display: flex;
gap: 1.25rem; gap: 1.25rem;
color: $gray-500; color: var(--pgn-color-gray-500);
overflow: hidden; overflow: hidden;
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -156,14 +156,14 @@
} }
.courseware-search-results-tabs { .courseware-search-results-tabs {
border-bottom-color: $gray-400 !important; border-bottom-color: var(--pgn-color-gray-400) !important;
&.nav-tabs .nav-link.active { &.nav-tabs .nav-link.active {
border-bottom-width: 4px !important; border-bottom-width: 4px !important;
} }
} }
@media (min-width: map-get($grid-breakpoints, 'md')) { @media (--pgn-size-breakpoint-min-width-md) {
.courseware-search { .courseware-search {
&__close { &__close {
right: -2.5rem; right: -2.5rem;

View File

@@ -10,8 +10,8 @@ describe('mapSearchResponse', () => {
response = mapSearchResponse(camelCaseObject(mockedResponse)); response = mapSearchResponse(camelCaseObject(mockedResponse));
}); });
it('should match snapshot', () => { it('should match number of results', () => {
expect(response).toMatchSnapshot(); expect(response.results.length).toBe(mockedResponse.results.length);
}); });
it('should match expected filters', () => { it('should match expected filters', () => {
@@ -24,6 +24,25 @@ describe('mapSearchResponse', () => {
]; ];
expect(response.filters).toEqual(expectedFilters); expect(response.filters).toEqual(expectedFilters);
}); });
it('should match expected results', () => {
const mockFirstResult = mockedResponse.results[0];
const expectedFirstResult = {
id: mockFirstResult.data.id,
title: mockFirstResult.data.content.display_name,
type: mockFirstResult.data.content_type.toLowerCase(),
location: mockFirstResult.data.location,
url: mockFirstResult.data.url,
contentHits: 0,
score: mockFirstResult.score,
};
expect(response.results[0]).toEqual(expectedFirstResult);
});
it('should match expected ms and max score', () => {
expect(response.maxScore).toBe(mockedResponse.max_score);
expect(response.ms).toBe(mockedResponse.took);
});
}); });
describe('when the a keyword is provided', () => { describe('when the a keyword is provided', () => {

View File

@@ -17,7 +17,21 @@ Factory.define('progressTabData')
percent: 1, percent: 1,
is_passing: true, is_passing: true,
}, },
final_grades: 0.5,
credit_course_requirements: null, credit_course_requirements: null,
assignment_type_grade_summary: [
{
type: 'Homework',
short_label: 'HW',
weight: 1,
average_grade: 1,
weighted_grade: 1,
num_droppable: 1,
num_total: 2,
has_hidden_contribution: 'none',
last_grade_publish_date: null,
},
],
section_scores: [ section_scores: [
{ {
display_name: 'First section', display_name: 'First section',

View File

@@ -1,941 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isNewDiscussionSidebarViewEnabled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"dates": {
"course-v1:edX+DemoX+Demo_Course": {
"courseDateBlocks": [
{
"date": "2020-05-01T17:59:41Z",
"dateType": "course-start-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Starts",
},
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Completed",
},
{
"assignmentType": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Past Due",
},
{
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 1",
},
{
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 2",
},
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 1",
},
{
"assignmentType": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 2",
},
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 1",
},
{
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 2",
},
{
"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.",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
},
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "One Verified 1",
},
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Verified 2",
},
{
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": "ORA Dates are set by the instructor, and can't be changed",
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "ORA Verified 2",
},
{
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 1",
},
{
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 2",
},
{
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"learnerHasAccess": true,
"title": "One Unreleased 1",
},
{
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Unreleased 2",
},
{
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 1",
},
{
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 2",
},
{
"date": "2030-08-23T00:00:00Z",
"dateType": "course-end-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Ends",
},
{
"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.",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Verification Deadline",
},
],
"datesBannerInfo": {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"learnerIsFullAccess": true,
},
},
},
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": [],
"name": "",
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isNewDiscussionSidebarViewEnabled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"outline": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"certData": {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
},
"courseBlocks": {
"courses": {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": {
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionIds": [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"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": [
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
"title": "Title of Section",
},
},
"sequences": {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": {
"complete": false,
"description": null,
"due": null,
"effortActivities": 2,
"effortTime": 15,
"hideFromTOC": undefined,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"navigationDisabled": undefined,
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true,
"title": "Title of Sequence",
},
},
},
"courseGoals": {
"daysPerWeek": null,
"goalOptions": [],
"selectedGoal": null,
"subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
},
"courseTools": [
{
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "https://example.com/bookmarks",
},
],
"datesBannerInfo": {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
},
"datesWidget": {
"courseDateBlocks": [],
},
"enableProctoredExams": undefined,
"enrollAlert": {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
"enrollmentMode": undefined,
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined,
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course",
"offer": null,
"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": {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "ABCD1234",
"upgradeUrl": "http://localhost:18000/dashboard",
},
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
},
},
},
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": [],
"name": "",
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
{
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": {
"courseId": null,
"courseOutline": {},
"courseOutlineShouldUpdate": false,
"courseOutlineStatus": "loading",
"courseStatus": "loading",
"coursewareOutlineSidebarSettings": {},
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": {
"courseHomeMeta": {
"course-v1:edX+DemoX+Demo_Course": {
"canViewCertificate": true,
"celebrations": null,
"courseAccess": {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false,
"isMasquerading": false,
"isNewDiscussionSidebarViewEnabled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": [
{
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
},
{
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
{
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
{
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
{
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
{
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"progress": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"certificateData": {},
"completionSummary": {
"completeCount": 1,
"incompleteCount": 1,
"lockedCount": 0,
},
"courseGrade": {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
},
"courseId": "course-v1:edX+DemoX+Demo_Course",
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": {
"assignmentPolicies": [
{
"averageGrade": "1.0000",
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"gradeRange": {
"pass": 0.75,
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionScores": [
{
"displayName": "First section",
"subsections": [
{
"assignmentType": "Homework",
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection",
"hasGradedAssignment": true,
"learnerHasAccess": true,
"numPointsEarned": 0,
"numPointsPossible": 3,
"percentGraded": 0,
"problemScores": [
{
"earned": 0,
"possible": 1,
},
{
"earned": 0,
"possible": 1,
},
{
"earned": 0,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
},
],
},
{
"displayName": "Second section",
"subsections": [
{
"assignmentType": "Homework",
"displayName": "Second subsection",
"hasGradedAssignment": true,
"numPointsEarned": 1,
"numPointsPossible": 1,
"percentGraded": 1,
"problemScores": [
{
"earned": 1,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",
},
],
},
],
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
"userHasPassingGrade": false,
"verificationData": {
"link": null,
"status": "none",
"statusDate": null,
},
"verifiedMode": null,
},
},
},
"plugins": {},
"recommendations": {
"recommendationsStatus": "loading",
},
"specialExams": {
"activeAttempt": null,
"allowProctoringOptOut": false,
"apiErrorMsg": "",
"exam": {
"attempt": {
"attempt_code": "",
"attempt_id": null,
"attempt_status": "",
"course_id": "",
"desktop_application_js_url": "",
"exam_display_name": "",
"exam_started_poll_url": "",
"exam_type": "",
"exam_url_path": "",
"external_id": "",
"in_timed_exam": true,
"ping_interval": null,
"taking_as_proctored": true,
"time_remaining_seconds": null,
"use_legacy_attempt_api": true,
},
"backend": "",
"content_id": "",
"course_id": "",
"due_date": null,
"exam_name": "",
"external_id": "",
"hide_after_due": false,
"id": null,
"is_active": true,
"is_practice_exam": false,
"is_proctored": false,
"prerequisite_status": {
"are_prerequisites_satisifed": true,
"declined_prerequisites": [],
"failed_prerequisites": [],
"pending_prerequisites": [],
"satisfied_prerequisites": [],
},
"time_limit_mins": null,
"type": "",
},
"examAccessToken": {
"exam_access_token": "",
"exam_access_token_expiration": "",
},
"isLoading": true,
"proctoringSettings": {
"exam_proctoring_backend": {
"download_url": "",
"instructions": [],
"name": "",
"rules": {},
},
"integration_specific_email": "",
"learner_notification_from_email": "",
"provider_name": "",
"provider_tech_support_email": "",
"provider_tech_support_phone": "",
"provider_tech_support_url": "",
},
"timeIsOver": false,
},
"tours": {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;

View File

@@ -3,93 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging'; import { logInfo } from '@edx/frontend-platform/logging';
import { appendBrowserTimezoneToUrl } from '../../utils'; import { appendBrowserTimezoneToUrl } from '../../utils';
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
let dropCount = numDroppable;
// Drop the lowest grades
while (dropCount && points.length >= dropCount) {
const lowestScore = Math.min(...points);
const lowestScoreIndex = points.indexOf(lowestScore);
points.splice(lowestScoreIndex, 1);
dropCount--;
}
let averageGrade = 0;
let weightedGrade = 0;
if (points.length) {
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
// exists in edx-platform.
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
};
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
const gradeByAssignmentType = {};
assignmentPolicies.forEach(assignment => {
// Create an array with the number of total assignments and set the scores to 0
// as placeholders for assignments that have not yet been released
gradeByAssignmentType[assignment.type] = {
grades: Array(assignment.numTotal).fill(0),
numAssignmentsCreated: 0,
numTotalExpectedAssignments: assignment.numTotal,
};
});
sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
return;
}
const {
assignmentType,
numPointsEarned,
numPointsPossible,
} = subsection;
// If a subsection's assignment type does not match an assignment policy in Studio,
// we won't be able to include it in this accumulation of grades by assignment type.
// This may happen if a course author has removed/renamed an assignment policy in Studio and
// neglected to update the subsection's of that assignment type
if (!gradeByAssignmentType[assignmentType]) {
return;
}
let {
numAssignmentsCreated,
} = gradeByAssignmentType[assignmentType];
numAssignmentsCreated++;
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
// of expected assignments
gradeByAssignmentType[assignmentType].grades.shift();
}
// Add the graded assignment to the list
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
// Record the created assignment
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
});
});
return assignmentPolicies.map((assignment) => {
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
gradeByAssignmentType[assignment.type].grades,
assignment.weight,
assignment.numDroppable,
);
return {
averageGrade,
numDroppable: assignment.numDroppable,
shortLabel: assignment.shortLabel,
type: assignment.type,
weight: assignment.weight,
weightedGrade,
};
});
}
/** /**
* Tweak the metadata for consistency * Tweak the metadata for consistency
* @param metadata the data to normalize * @param metadata the data to normalize
@@ -236,11 +149,6 @@ export async function getProgressTabData(courseId, targetUserId) {
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
const camelCasedData = camelCaseObject(data); const camelCasedData = camelCaseObject(data);
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
camelCasedData.gradingPolicy.assignmentPolicies,
camelCasedData.sectionScores,
);
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade. // We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a") // For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
// in order to preserve a course team's desired grade formatting. // in order to preserve a course team's desired grade formatting.
@@ -471,3 +379,24 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option
return camelCaseObject(response); return camelCaseObject(response);
} }
export async function getExamsData(courseId, sequenceId) {
let url;
if (!getConfig().EXAMS_BASE_URL) {
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
} else {
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
}
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return {};
}
throw error;
}
}

View File

@@ -1,4 +1,12 @@
import { getTimeOffsetMillis } from './api'; import { getConfig, setConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { getTimeOffsetMillis, getExamsData } from './api';
import { initializeMockApp } from '../../setupTest';
initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Calculate the time offset properly', () => { describe('Calculate the time offset properly', () => {
it('Should return 0 if the headerDate is not set', async () => { it('Should return 0 if the headerDate is not set', async () => {
@@ -14,3 +22,156 @@ describe('Calculate the time offset properly', () => {
expect(offset).toBe(86398750); expect(offset).toBe(86398750);
}); });
}); });
describe('getExamsData', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345';
let originalConfig;
beforeEach(() => {
axiosMock.reset();
originalConfig = getConfig();
});
afterEach(() => {
axiosMock.reset();
if (originalConfig) {
setConfig(originalConfig);
}
});
it('should use LMS URL when EXAMS_BASE_URL is not configured', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: undefined,
LMS_BASE_URL: 'http://localhost:18000',
});
const mockExamData = {
exam: {
id: 1,
course_id: courseId,
content_id: sequenceId,
exam_name: 'Test Exam',
attempt_status: 'created',
},
};
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
const result = await getExamsData(courseId, sequenceId);
expect(result).toEqual({
exam: {
id: 1,
courseId,
contentId: sequenceId,
examName: 'Test Exam',
attemptStatus: 'created',
},
});
expect(axiosMock.history.get).toHaveLength(1);
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
});
it('should use EXAMS_BASE_URL when configured', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: 'http://localhost:18740',
LMS_BASE_URL: 'http://localhost:18000',
});
const mockExamData = {
exam: {
id: 1,
course_id: courseId,
content_id: sequenceId,
exam_name: 'Test Exam',
attempt_status: 'submitted',
},
};
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
const result = await getExamsData(courseId, sequenceId);
expect(result).toEqual({
exam: {
id: 1,
courseId,
contentId: sequenceId,
examName: 'Test Exam',
attemptStatus: 'submitted',
},
});
expect(axiosMock.history.get).toHaveLength(1);
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
});
it('should return empty object when API returns 404', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: undefined,
LMS_BASE_URL: 'http://localhost:18000',
});
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
// Mock a 404 error with the custom error response function to add customAttributes
axiosMock.onGet(expectedUrl).reply(() => {
const error = new Error('Request failed with status code 404');
error.response = { status: 404, data: {} };
error.customAttributes = { httpErrorStatus: 404 };
return Promise.reject(error);
});
const result = await getExamsData(courseId, sequenceId);
expect(result).toEqual({});
expect(axiosMock.history.get).toHaveLength(1);
});
it('should throw error for non-404 HTTP errors', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: undefined,
LMS_BASE_URL: 'http://localhost:18000',
});
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
// Mock a 500 error with custom error response
axiosMock.onGet(expectedUrl).reply(() => {
const error = new Error('Request failed with status code 500');
error.response = { status: 500, data: { error: 'Server Error' } };
error.customAttributes = { httpErrorStatus: 500 };
return Promise.reject(error);
});
await expect(getExamsData(courseId, sequenceId)).rejects.toThrow();
expect(axiosMock.history.get).toHaveLength(1);
});
it('should properly encode URL parameters', async () => {
setConfig({
...originalConfig,
EXAMS_BASE_URL: 'http://localhost:18740',
LMS_BASE_URL: 'http://localhost:18000',
});
const specialCourseId = 'course-v1:edX+Demo X+Demo Course';
const specialSequenceId = 'block-v1:edX+Demo X+Demo Course+type@sequential+block@test sequence';
const mockExamData = { exam: { id: 1 } };
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(specialCourseId)}/content_id/${encodeURIComponent(specialSequenceId)}`;
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
await getExamsData(specialCourseId, specialSequenceId);
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
expect(axiosMock.history.get[0].url).toContain('course-v1%3AedX%2BDemo%20X%2BDemo%20Course');
expect(axiosMock.history.get[0].url).toContain('block-v1%3AedX%2BDemo%20X%2BDemo%20Course%2Btype%40sequential%2Bblock%40test%20sequence');
});
});

View File

@@ -90,14 +90,14 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toEqual(expect.objectContaining({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID // The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID // to keep track of conversations. This UUID is generated on each run.
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here. // Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({ learningAssistant: expect.objectContaining({
conversationId: expect.any(String), conversationId: expect.any(String),
}), }),
}); }));
}); });
it.each([401, 403, 404])( it.each([401, 403, 404])(
@@ -137,14 +137,14 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toEqual(expect.objectContaining({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID // The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID // to keep track of conversations. This UUID is generated on each run.
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here. // Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({ learningAssistant: expect.objectContaining({
conversationId: expect.any(String), conversationId: expect.any(String),
}), }),
}); }));
}); });
it.each([401, 403, 404])( it.each([401, 403, 404])(
@@ -185,14 +185,14 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toEqual(expect.objectContaining({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID // The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID // to keep track of conversations. This UUID is generated on each run.
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here. // Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({ learningAssistant: expect.objectContaining({
conversationId: expect.any(String), conversationId: expect.any(String),
}), }),
}); }));
}); });
it('Should handle the url including a targetUserId', async () => { it('Should handle the url including a targetUserId', async () => {
@@ -297,4 +297,178 @@ describe('Data layer integration tests', () => {
expect(enabled).toBe(false); expect(enabled).toBe(false);
}); });
}); });
describe('Test fetchExamAttemptsData', () => {
const sequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@abcde',
];
beforeEach(() => {
// Mock individual exam endpoints with different responses
sequenceIds.forEach((sequenceId, index) => {
// Handle both LMS and EXAMS service URL patterns
const lmsExamUrl = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceId)}.*`);
const examsServiceUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
let attemptStatus = 'ready_to_start';
if (index === 0) {
attemptStatus = 'created';
} else if (index === 1) {
attemptStatus = 'submitted';
}
const mockExamData = {
exam: {
id: index + 1,
course_id: courseId,
content_id: sequenceId,
exam_name: `Test Exam ${index + 1}`,
attempt_status: attemptStatus,
time_remaining_seconds: 3600,
},
};
// Mock both URL patterns
axiosMock.onGet(lmsExamUrl).reply(200, mockExamData);
axiosMock.onGet(examsServiceUrl).reply(200, mockExamData);
});
});
it('should fetch exam data for all sequence IDs and dispatch setExamsData', async () => {
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify the examsData was set in the store
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData).toEqual([
{
id: 1,
courseId,
contentId: sequenceIds[0],
examName: 'Test Exam 1',
attemptStatus: 'created',
timeRemainingSeconds: 3600,
},
{
id: 2,
courseId,
contentId: sequenceIds[1],
examName: 'Test Exam 2',
attemptStatus: 'submitted',
timeRemainingSeconds: 3600,
},
{
id: 3,
courseId,
contentId: sequenceIds[2],
examName: 'Test Exam 3',
attemptStatus: 'ready_to_start',
timeRemainingSeconds: 3600,
},
]);
// Verify all API calls were made
expect(axiosMock.history.get).toHaveLength(3);
});
it('should handle 404 responses and include empty objects in results', async () => {
// Override one endpoint to return 404 for both URL patterns
const examUrl404LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl404Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
axiosMock.onGet(examUrl404LMS).reply(404);
axiosMock.onGet(examUrl404Exams).reply(404);
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify the examsData includes empty object for 404 response
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData[1]).toEqual({});
});
it('should handle API errors and log them while continuing with other requests', async () => {
// Override one endpoint to return 500 error for both URL patterns
const examUrl500LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl500Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
axiosMock.onGet(examUrl500LMS).reply(500, { error: 'Server Error' });
axiosMock.onGet(examUrl500Exams).reply(500, { error: 'Server Error' });
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify error was logged for the failed request
expect(loggingService.logError).toHaveBeenCalled();
// Verify the examsData still includes results for successful requests
expect(state.courseHome.examsData).toHaveLength(3);
// First item should be the error result (just empty object for API errors)
expect(state.courseHome.examsData[0]).toEqual({});
});
it('should handle empty sequence IDs array', async () => {
await executeThunk(thunks.fetchExamAttemptsData(courseId, []), store.dispatch);
const state = store.getState();
expect(state.courseHome.examsData).toEqual([]);
expect(axiosMock.history.get).toHaveLength(0);
});
it('should handle mixed success and error responses', async () => {
// Setup mixed responses
const examUrl1LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl1Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl2LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl2Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl3LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[2])}.*`);
const examUrl3Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[2])}.*`);
axiosMock.onGet(examUrl1LMS).reply(200, {
exam: {
id: 1,
exam_name: 'Success Exam',
course_id: courseId,
content_id: sequenceIds[0],
attempt_status: 'created',
time_remaining_seconds: 3600,
},
});
axiosMock.onGet(examUrl1Exams).reply(200, {
exam: {
id: 1,
exam_name: 'Success Exam',
course_id: courseId,
content_id: sequenceIds[0],
attempt_status: 'created',
time_remaining_seconds: 3600,
},
});
axiosMock.onGet(examUrl2LMS).reply(404);
axiosMock.onGet(examUrl2Exams).reply(404);
axiosMock.onGet(examUrl3LMS).reply(500, { error: 'Server Error' });
axiosMock.onGet(examUrl3Exams).reply(500, { error: 'Server Error' });
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData[0]).toMatchObject({
id: 1,
examName: 'Success Exam',
courseId,
contentId: sequenceIds[0],
});
expect(state.courseHome.examsData[1]).toEqual({});
expect(state.courseHome.examsData[2]).toEqual({});
// Verify error was logged for the 500 error (may be called more than once due to multiple URL patterns)
expect(loggingService.logError).toHaveBeenCalled();
});
});
}); });

View File

@@ -18,6 +18,7 @@ const slice = createSlice({
toastBodyLink: null, toastBodyLink: null,
toastHeader: '', toastHeader: '',
showSearch: false, showSearch: false,
examsData: null,
}, },
reducers: { reducers: {
fetchProctoringInfoResolved: (state) => { fetchProctoringInfoResolved: (state) => {
@@ -53,6 +54,9 @@ const slice = createSlice({
setShowSearch: (state, { payload }) => { setShowSearch: (state, { payload }) => {
state.showSearch = payload; state.showSearch = payload;
}, },
setExamsData: (state, { payload }) => {
state.examsData = payload;
},
}, },
}); });
@@ -64,6 +68,7 @@ export const {
fetchTabSuccess, fetchTabSuccess,
setCallToActionToast, setCallToActionToast,
setShowSearch, setShowSearch,
setExamsData,
} = slice.actions; } = slice.actions;
export const { export const {

View File

@@ -0,0 +1,145 @@
import { reducer, setExamsData } from './slice';
describe('course home data slice', () => {
describe('setExamsData reducer', () => {
it('should set examsData in state', () => {
const initialState = {
courseStatus: 'loading',
courseId: null,
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
toastBodyText: '',
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: null,
};
const mockExamsData = [
{
id: 1,
courseId: 'course-v1:edX+DemoX+Demo_Course',
examName: 'Midterm Exam',
attemptStatus: 'created',
},
{
id: 2,
courseId: 'course-v1:edX+DemoX+Demo_Course',
examName: 'Final Exam',
attemptStatus: 'submitted',
},
];
const action = setExamsData(mockExamsData);
const newState = reducer(initialState, action);
expect(newState.examsData).toEqual(mockExamsData);
expect(newState).toEqual({
...initialState,
examsData: mockExamsData,
});
});
it('should update examsData when state already has data', () => {
const initialState = {
courseStatus: 'loaded',
courseId: 'test-course',
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
toastBodyText: '',
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: [{ id: 1, examName: 'Old Exam' }],
};
const newExamsData = [
{
id: 2,
courseId: 'course-v1:edX+DemoX+Demo_Course',
examName: 'New Exam',
attemptStatus: 'ready_to_start',
},
];
const action = setExamsData(newExamsData);
const newState = reducer(initialState, action);
expect(newState.examsData).toEqual(newExamsData);
expect(newState.examsData).not.toEqual(initialState.examsData);
});
it('should set examsData to empty array', () => {
const initialState = {
courseStatus: 'loaded',
courseId: 'test-course',
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
toastBodyText: '',
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: [{ id: 1, examName: 'Some Exam' }],
};
const action = setExamsData([]);
const newState = reducer(initialState, action);
expect(newState.examsData).toEqual([]);
});
it('should set examsData to null', () => {
const initialState = {
courseStatus: 'loaded',
courseId: 'test-course',
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
toastBodyText: '',
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: [{ id: 1, examName: 'Some Exam' }],
};
const action = setExamsData(null);
const newState = reducer(initialState, action);
expect(newState.examsData).toBeNull();
});
it('should not affect other state properties when setting examsData', () => {
const initialState = {
courseStatus: 'loaded',
courseId: 'test-course-id',
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'complete',
tabFetchStates: { progress: 'loaded' },
toastBodyText: 'Toast message',
toastBodyLink: 'http://example.com',
toastHeader: 'Toast Header',
showSearch: true,
examsData: null,
};
const mockExamsData = [{ id: 1, examName: 'Test Exam' }];
const action = setExamsData(mockExamsData);
const newState = reducer(initialState, action);
// Verify that only examsData changed
expect(newState).toEqual({
...initialState,
examsData: mockExamsData,
});
// Verify other properties remain unchanged
expect(newState.courseStatus).toBe(initialState.courseStatus);
expect(newState.courseId).toBe(initialState.courseId);
expect(newState.showSearch).toBe(initialState.showSearch);
expect(newState.toastBodyText).toBe(initialState.toastBodyText);
});
});
});

View File

@@ -4,6 +4,7 @@ import {
executePostFromPostEvent, executePostFromPostEvent,
getCourseHomeCourseMetadata, getCourseHomeCourseMetadata,
getDatesTabData, getDatesTabData,
getExamsData,
getOutlineTabData, getOutlineTabData,
getProgressTabData, getProgressTabData,
postCourseDeadlines, postCourseDeadlines,
@@ -26,6 +27,7 @@ import {
fetchTabRequest, fetchTabRequest,
fetchTabSuccess, fetchTabSuccess,
setCallToActionToast, setCallToActionToast,
setExamsData,
} from './slice'; } from './slice';
import mapSearchResponse from '../courseware-search/map-search-response'; import mapSearchResponse from '../courseware-search/map-search-response';
@@ -223,3 +225,19 @@ export function searchCourseContent(courseId, searchKeyword) {
}); });
}; };
} }
export function fetchExamAttemptsData(courseId, sequenceIds) {
return async (dispatch) => {
const results = await Promise.all(sequenceIds.map(async (sequenceId) => {
try {
const response = await getExamsData(courseId, sequenceId);
return response.exam || {};
} catch (e) {
logError(e);
return {};
}
}));
dispatch(setExamsData(results));
};
}

View File

@@ -65,6 +65,7 @@ const DateSummary = ({
)} )}
{!linkedTitle && dateBlock.link && ( {!linkedTitle && dateBlock.link && (
<a <a
id={dateBlock.dateType === 'verified-upgrade-deadline' ? 'date-verified-upgrade-deadline' : ''}
href={dateBlock.link} href={dateBlock.link}
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}} onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
className="description-link" className="description-link"

View File

@@ -204,122 +204,122 @@ const messages = defineMessages({
notStartedProctoringStatus: { notStartedProctoringStatus: {
id: 'learning.proctoringPanel.status.notStarted', id: 'learning.proctoringPanel.status.notStarted',
defaultMessage: 'Not Started', defaultMessage: 'Not Started',
description: 'It indcate that proctortrack onboarding exam hasnt started yet', description: 'It indcate that proctored onboarding exam hasnt started yet',
}, },
startedProctoringStatus: { startedProctoringStatus: {
id: 'learning.proctoringPanel.status.started', id: 'learning.proctoringPanel.status.started',
defaultMessage: 'Started', defaultMessage: 'Started',
description: 'Label to indicate the starting status of the proctortrack onboarding exam', description: 'Label to indicate the starting status of the proctored onboarding exam',
}, },
submittedProctoringStatus: { submittedProctoringStatus: {
id: 'learning.proctoringPanel.status.submitted', id: 'learning.proctoringPanel.status.submitted',
defaultMessage: 'Submitted', defaultMessage: 'Submitted',
description: 'Label to indicate the submitted status of proctortrack onboarding exam', description: 'Label to indicate the submitted status of proctored onboarding exam',
}, },
verifiedProctoringStatus: { verifiedProctoringStatus: {
id: 'learning.proctoringPanel.status.verified', id: 'learning.proctoringPanel.status.verified',
defaultMessage: 'Verified', defaultMessage: 'Verified',
description: 'Label to indicate the verified status of the proctortrack onboarding exam', description: 'Label to indicate the verified status of the proctored onboarding exam',
}, },
rejectedProctoringStatus: { rejectedProctoringStatus: {
id: 'learning.proctoringPanel.status.rejected', id: 'learning.proctoringPanel.status.rejected',
defaultMessage: 'Rejected', defaultMessage: 'Rejected',
description: 'Label to indicate the rejection status of the proctortrack onboarding exam', description: 'Label to indicate the rejection status of the proctored onboarding exam',
}, },
errorProctoringStatus: { errorProctoringStatus: {
id: 'learning.proctoringPanel.status.error', id: 'learning.proctoringPanel.status.error',
defaultMessage: 'Error', defaultMessage: 'Error',
description: 'Label to indicate that there is error in proctortrack onboarding exam', description: 'Label to indicate that there is error in proctored onboarding exam',
}, },
otherCourseApprovedProctoringStatus: { otherCourseApprovedProctoringStatus: {
id: 'learning.proctoringPanel.status.otherCourseApproved', id: 'learning.proctoringPanel.status.otherCourseApproved',
defaultMessage: 'Approved in Another Course', defaultMessage: 'Approved in Another Course',
description: 'Label to indicate that the proctortrack onboarding exam is verified based on taking onboarding exam on another course', description: 'Label to indicate that the proctored onboarding exam is verified based on taking onboarding exam on another course',
}, },
expiringSoonProctoringStatus: { expiringSoonProctoringStatus: {
id: 'learning.proctoringPanel.status.expiringSoon', id: 'learning.proctoringPanel.status.expiringSoon',
defaultMessage: 'Expiring Soon', defaultMessage: 'Expiring Soon',
description: 'A label to indicate that proctortrack onboarding exam will expire soon', description: 'A label to indicate that proctored onboarding exam will expire soon',
}, },
expiredProctoringStatus: { expiredProctoringStatus: {
id: 'learning.proctoringPanel.status.expired', id: 'learning.proctoringPanel.status.expired',
defaultMessage: 'Expired', defaultMessage: 'Expired',
description: 'A label to indicate that proctortrack onboarding exam has expired', description: 'A label to indicate that proctored onboarding exam has expired',
}, },
proctoringCurrentStatus: { proctoringCurrentStatus: {
id: 'learning.proctoringPanel.status', id: 'learning.proctoringPanel.status',
defaultMessage: 'Current Onboarding Status:', defaultMessage: 'Current Onboarding Status:',
description: 'The text that precede the status label of proctortrack onboarding exam', description: 'The text that precede the status label of proctored onboarding exam',
}, },
notStartedProctoringMessage: { notStartedProctoringMessage: {
id: 'learning.proctoringPanel.message.notStarted', id: 'learning.proctoringPanel.message.notStarted',
defaultMessage: 'You have not started your onboarding exam.', defaultMessage: 'You have not started your onboarding exam.',
description: 'The text that explain the meaning of (not started) label of the proctortrack onboarding exam', description: 'The text that explain the meaning of (not started) label of the proctored onboarding exam',
}, },
startedProctoringMessage: { startedProctoringMessage: {
id: 'learning.proctoringPanel.message.started', id: 'learning.proctoringPanel.message.started',
defaultMessage: 'You have started your onboarding exam.', defaultMessage: 'You have started your onboarding exam.',
description: 'The text that explain the meaning of (started) label of the proctortrack onboarding exam', description: 'The text that explain the meaning of (started) label of the proctored onboarding exam',
}, },
submittedProctoringMessage: { submittedProctoringMessage: {
id: 'learning.proctoringPanel.message.submitted', id: 'learning.proctoringPanel.message.submitted',
defaultMessage: 'You have submitted your onboarding exam.', defaultMessage: 'You have submitted your onboarding exam.',
description: 'The text that explain the meaning of (submitted) label of the proctortrack onboarding exam', description: 'The text that explain the meaning of (submitted) label of the proctored onboarding exam',
}, },
verifiedProctoringMessage: { verifiedProctoringMessage: {
id: 'learning.proctoringPanel.message.verified', id: 'learning.proctoringPanel.message.verified',
defaultMessage: 'Your onboarding exam has been approved in this course.', defaultMessage: 'Your onboarding exam has been approved in this course.',
description: 'The text that explain the meaning of (verified) label of the proctortrack onboarding exam', description: 'The text that explain the meaning of (verified) label of the proctored onboarding exam',
}, },
rejectedProctoringMessage: { rejectedProctoringMessage: {
id: 'learning.proctoringPanel.message.rejected', id: 'learning.proctoringPanel.message.rejected',
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.', defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
description: 'The text that explain the meaning of (rejected) label of the proctortrack onboarding exam', description: 'The text that explain the meaning of (rejected) label of the proctored onboarding exam',
}, },
errorProctoringMessage: { errorProctoringMessage: {
id: 'learning.proctoringPanel.message.error', id: 'learning.proctoringPanel.message.error',
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.', defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
description: 'The text that explain the meaning of (error) label of the proctortrack onboarding exam', description: 'The text that explain the meaning of (error) label of the proctored onboarding exam',
}, },
otherCourseApprovedProctoringMessage: { otherCourseApprovedProctoringMessage: {
id: 'learning.proctoringPanel.message.otherCourseApproved', id: 'learning.proctoringPanel.message.otherCourseApproved',
defaultMessage: 'Your onboarding exam has been approved in another course.', defaultMessage: 'Your onboarding exam has been approved in another course.',
description: 'The text that explain the meaning of (approved in another course) label of the proctortrack onboarding exam', description: 'The text that explain the meaning of (approved in another course) label of the proctored onboarding exam',
}, },
otherCourseApprovedProctoringDetail: { otherCourseApprovedProctoringDetail: {
id: 'learning.proctoringPanel.detail.otherCourseApproved', id: 'learning.proctoringPanel.detail.otherCourseApproved',
defaultMessage: 'If your device has changed, we recommend that you complete this course\'s onboarding exam in order to ensure that your setup still meets the requirements for proctoring.', defaultMessage: 'If your device has changed, we recommend that you complete this course\'s onboarding exam in order to ensure that your setup still meets the requirements for proctoring.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (approved in another course)', description: 'The text that recommend an action when the status of the proctored onboarding exam is (approved in another course)',
}, },
expiringSoonProctoringMessage: { expiringSoonProctoringMessage: {
id: 'learning.proctoringPanel.message.expiringSoon', id: 'learning.proctoringPanel.message.expiringSoon',
defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.', defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)', description: 'The text that recommend an action when the status of the proctored onboarding exam is (expiring soon)',
}, },
expiredProctoringMessage: { expiredProctoringMessage: {
id: 'learning.proctoringPanel.message.expired', id: 'learning.proctoringPanel.message.expired',
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.', defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)', description: 'The text that recommend an action when the status of the proctored onboarding exam is (expired)',
}, },
proctoringPanelGeneralInfo: { proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo', id: 'learning.proctoringPanel.generalInfo',
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ', defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
description: 'It indicate key and important fact to learner about the importance of taking proctortrack onboarding exam', description: 'It indicate key and important fact to learner about the importance of taking proctored onboarding exam',
}, },
proctoringPanelGeneralInfoSubmitted: { proctoringPanelGeneralInfoSubmitted: {
id: 'learning.proctoringPanel.generalInfoSubmitted', id: 'learning.proctoringPanel.generalInfoSubmitted',
defaultMessage: 'Your submitted profile is in review.', defaultMessage: 'Your submitted profile is in review.',
description: 'The text that explain the meaning of (in review) label of the proctortrack onboarding exam', description: 'The text that explain the meaning of (in review) label of the proctored onboarding exam',
}, },
proctoringPanelGeneralTime: { proctoringPanelGeneralTime: {
id: 'learning.proctoringPanel.generalTime', id: 'learning.proctoringPanel.generalTime',
defaultMessage: 'Onboarding profile review can take 2+ business days.', defaultMessage: 'Onboarding profile review can take 2+ business days.',
description: 'This text explain for how long the (in review) status of the proctortrack onboarding exam might remain', description: 'This text explain for how long the (in review) status of the proctored onboarding exam might remain',
}, },
proctoringOnboardingButton: { proctoringOnboardingButton: {
id: 'learning.proctoringPanel.onboardingButton', id: 'learning.proctoringPanel.onboardingButton',
defaultMessage: 'Complete Onboarding', defaultMessage: 'Complete Onboarding',
description: 'Text shown on the button that starts the actual proctortrack onboarding exam when it is released', description: 'Text shown on the button that starts the actual proctored onboarding exam when it is released',
}, },
proctoringOnboardingPracticeButton: { proctoringOnboardingPracticeButton: {
id: 'learning.proctoringPanel.onboardingPracticeButton', id: 'learning.proctoringPanel.onboardingPracticeButton',
@@ -329,17 +329,17 @@ const messages = defineMessages({
proctoringOnboardingButtonNotOpen: { proctoringOnboardingButtonNotOpen: {
id: 'learning.proctoringPanel.onboardingButtonNotOpen', id: 'learning.proctoringPanel.onboardingButtonNotOpen',
defaultMessage: 'Onboarding Opens: {releaseDate}', defaultMessage: 'Onboarding Opens: {releaseDate}',
description: 'It indicate when or from when the learner can take the proctortrack onboarding exam', description: 'It indicate when or from when the learner can take the proctored onboarding exam',
}, },
proctoringReviewRequirementsButton: { proctoringReviewRequirementsButton: {
id: 'learning.proctoringPanel.reviewRequirementsButton', id: 'learning.proctoringPanel.reviewRequirementsButton',
defaultMessage: 'Review instructions and system requirements', defaultMessage: 'Review instructions and system requirements',
description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctortrack onboarding exam', description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctored onboarding exam',
}, },
proctoringOnboardingButtonPastDue: { proctoringOnboardingButtonPastDue: {
id: 'learning.proctoringPanel.onboardingButtonPastDue', id: 'learning.proctoringPanel.onboardingButtonPastDue',
defaultMessage: 'Onboarding Past Due', defaultMessage: 'Onboarding Past Due',
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason', description: 'Text that show when the deadline of proctored onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
}, },
sequenceDueDate: { sequenceDueDate: {
id: 'learning.outline.sequence-due-date-set', id: 'learning.outline.sequence-due-date-set',

View File

@@ -39,7 +39,7 @@ const CourseDates = () => {
/> />
))} ))}
</ol> </ol>
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}> <a id="dates-tab-link" className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
{intl.formatMessage(messages.allDates)} {intl.formatMessage(messages.allDates)}
</a> </a>
</div> </div>

View File

@@ -1,22 +1,18 @@
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
.flag-button { .flag-button {
background-color: $white; background-color: var(--pgn-color-white);
border: 1px solid $light-400; border: 1px solid var(--pgn-color-light-400);
border-radius: .2rem; border-radius: .2rem;
box-shadow: 0 0 0 2px $light-400; box-shadow: 0 0 0 2px var(--pgn-color-light-400);
&:hover { &:hover {
border: 1px solid $primary-300; border: 1px solid var(--pgn-color-primary-300);
box-shadow: 0 0 0 2px $white; box-shadow: 0 0 0 2px var(--pgn-color-white);
} }
} }
.flag-button-selected { .flag-button-selected {
border: 1px solid $primary-300; border: 1px solid var(--pgn-color-primary-300);
box-shadow: 0 0 0 2px $primary-300; box-shadow: 0 0 0 2px var(--pgn-color-primary-300);
pointer-events: none; pointer-events: none;
} }

View File

@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import camelCase from 'lodash.camelcase'; import camelCase from 'lodash.camelcase';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { getExternalLinkUrl } from '@edx/frontend-platform';
import { Button } from '@openedx/paragon'; import { Button } from '@openedx/paragon';
import messages from '../messages'; import messages from '../messages';
@@ -207,7 +208,7 @@ const ProctoringInfoPanel = () => {
{isSubmissionRequired(readableStatus) && ( {isSubmissionRequired(readableStatus) && (
onboardingExamButton onboardingExamButton
)} )}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams"> <Button variant="outline-primary" block href={getExternalLinkUrl('https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams')}>
{intl.formatMessage(messages.proctoringReviewRequirementsButton)} {intl.formatMessage(messages.proctoringReviewRequirementsButton)}
</Button> </Button>
</div> </div>

View File

@@ -1,10 +1,10 @@
.outline-sidebar-proctoring-panel { .outline-sidebar-proctoring-panel {
border: 1px solid $dark-500; border: 1px solid var(--pgn-color-dark-500);
border-top: 5px solid $brand-600; border-top: 5px solid var(--pgn-color-brand-600);
} }
.proctoring-onboarding-success { .proctoring-onboarding-success {
border-top: 5px solid $primary-500; border-top: 5px solid var(--pgn-color-primary-500);
} }
.proctoring-onboarding-submitted { .proctoring-onboarding-submitted {
border-top: 5px solid $dark-500; border-top: 5px solid var(--pgn-color-dark-500);
} }

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React, { useMemo } from 'react';
import { useWindowSize } from '@openedx/paragon'; import { useWindowSize } from '@openedx/paragon';
import { useContextId } from '../../data/hooks'; import { useContextId } from '../../data/hooks';
import { useModel } from '../../generic/model-store';
import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot'; import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot';
import CourseCompletion from './course-completion/CourseCompletion'; import CourseCompletion from './course-completion/CourseCompletion';
@@ -10,11 +11,17 @@ import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/Progres
import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot'; import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot';
import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot'; import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot';
import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot'; import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot';
import { useModel } from '../../generic/model-store'; import { useGetExamsData } from './hooks';
const ProgressTab = () => { const ProgressTab = () => {
const courseId = useContextId(); const courseId = useContextId();
const { disableProgressGraph } = useModel('progress', courseId); const { disableProgressGraph, sectionScores } = useModel('progress', courseId);
const sequenceIds = useMemo(() => (
sectionScores.flatMap((section) => (section.subsections)).map((subsection) => subsection.blockKey)
), [sectionScores]);
useGetExamsData(courseId, sequenceIds);
const windowWidth = useWindowSize().width; const windowWidth = useWindowSize().width;
if (windowWidth === undefined) { if (windowWidth === undefined) {

View File

@@ -661,143 +661,133 @@ describe('Progress Tab', () => {
expect(screen.getByText('Grade summary')).toBeInTheDocument(); expect(screen.getByText('Grade summary')).toBeInTheDocument();
}); });
it('does not render Grade Summary when assignment policies are not populated', async () => { it('does not render Grade Summary when assignment type grade summary is not populated', async () => {
setTabData({ setTabData({
grading_policy: { assignment_type_grade_summary: [],
assignment_policies: [],
grade_range: {
pass: 0.75,
},
},
section_scores: [],
}); });
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument(); expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
}); });
it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => { it('shows lock icon when all subsections of assignment type are hidden', async () => {
setTabData({ setTabData({
grading_policy: { grading_policy: {
assignment_policies: [ assignment_policies: [
{
num_droppable: 2,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => {
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of droppable assignments is zero', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument();
});
it('calculates weighted grades correctly', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 0.5,
},
{ {
num_droppable: 0, num_droppable: 0,
num_total: 1, num_total: 1,
short_label: 'Ex', short_label: 'Final',
type: 'Exam', type: 'Final Exam',
weight: 0.5, weight: 1,
}, },
], ],
grade_range: { grade_range: {
pass: 0.75, pass: 0.75,
}, },
}, },
assignment_type_grade_summary: [
{
type: 'Final Exam',
weight: 0.4,
average_grade: 0.0,
weighted_grade: 0.0,
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
has_hidden_contribution: 'all',
short_label: 'Final',
num_droppable: 0,
},
],
}); });
await fetchAndRender(); await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument(); // Should show lock icon for grade and weighted grade
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}" expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument(); });
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
assignment_type_grade_summary: [
{
type: 'Homework',
weight: 1,
average_grade: 0.25,
weighted_grade: 0.25,
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
has_hidden_contribution: 'some',
short_label: 'HW',
num_droppable: 0,
},
],
});
await fetchAndRender();
// Should show percent + hidden scores for grade and weighted grade
const hiddenScoresCells = screen.getAllByText(/% \+ Hidden Scores/);
expect(hiddenScoresCells).toHaveLength(2);
// Only correct visible scores should be shown (from subsection2)
// The correct visible score is 1/4 = 0.25 -> 25%
expect(hiddenScoresCells[0]).toHaveTextContent('25% + Hidden Scores');
expect(hiddenScoresCells[1]).toHaveTextContent('25% + Hidden Scores');
});
it('displays a warning message with the latest due date when not all assignment scores are included in the total grade', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
assignment_type_grade_summary: [
{
type: 'Homework',
weight: 1,
average_grade: 1,
weighted_grade: 1,
last_grade_publish_date: tomorrow.toISOString(),
has_hidden_contribution: 'none',
short_label: 'HW',
num_droppable: 0,
},
],
});
await fetchAndRender();
const formattedDateTime = new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
}).format(tomorrow);
expect(
screen.getByText(
`Some assignment scores are not yet included in your total grade. These grades will be released by ${formattedDateTime}.`,
),
).toBeInTheDocument();
}); });
it('renders override notice', async () => { it('renders override notice', async () => {
@@ -1500,4 +1490,287 @@ describe('Progress Tab', () => {
expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument(); expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument();
}); });
}); });
describe('Exam data fetching integration', () => {
const mockSectionScores = [
{
display_name: 'Section 1',
subsections: [
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
display_name: 'Midterm Exam',
learner_has_access: true,
has_graded_assignment: true,
percent_graded: 0.8,
show_correctness: 'always',
show_grades: true,
url: '/mock-url',
},
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@homework1',
display_name: 'Homework 1',
learner_has_access: true,
has_graded_assignment: true,
percent_graded: 0.9,
show_correctness: 'always',
show_grades: true,
url: '/mock-url',
},
],
},
{
display_name: 'Section 2',
subsections: [
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
display_name: 'Final Exam',
learner_has_access: true,
has_graded_assignment: true,
percent_graded: 0.85,
show_correctness: 'always',
show_grades: true,
url: '/mock-url',
},
],
},
];
beforeEach(() => {
// Reset any existing handlers to avoid conflicts
axiosMock.reset();
// Re-add the base mocks that other tests expect
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
// Mock exam data endpoints using specific GET handlers
axiosMock.onGet(/.*exam1.*/).reply(200, {
exam: {
id: 1,
course_id: courseId,
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
exam_name: 'Midterm Exam',
attempt_status: 'submitted',
time_remaining_seconds: 0,
},
});
axiosMock.onGet(/.*homework1.*/).reply(404);
axiosMock.onGet(/.*final_exam.*/).reply(200, {
exam: {
id: 2,
course_id: courseId,
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
exam_name: 'Final Exam',
attempt_status: 'ready_to_start',
time_remaining_seconds: 7200,
},
});
});
it('should fetch exam data for all subsections when ProgressTab renders', async () => {
setTabData({ section_scores: mockSectionScores });
await fetchAndRender();
// Verify exam API calls were made for all subsections
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
// Verify the exam data is in the Redux store
const state = store.getState();
expect(state.courseHome.examsData).toHaveLength(3);
// Check the exam data structure
expect(state.courseHome.examsData[0]).toEqual({
id: 1,
courseId,
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
examName: 'Midterm Exam',
attemptStatus: 'submitted',
timeRemainingSeconds: 0,
});
expect(state.courseHome.examsData[1]).toEqual({}); // 404 response for homework
expect(state.courseHome.examsData[2]).toEqual({
id: 2,
courseId,
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
examName: 'Final Exam',
attemptStatus: 'ready_to_start',
timeRemainingSeconds: 7200,
});
});
it('should handle empty section scores gracefully', async () => {
setTabData({ section_scores: [] });
await fetchAndRender();
// Verify no exam API calls were made
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(0);
// Verify empty exam data in Redux store
const state = store.getState();
expect(state.courseHome.examsData).toEqual([]);
});
it('should re-fetch exam data when section scores change', async () => {
// Initial render with limited section scores
setTabData({
section_scores: [mockSectionScores[0]], // Only first section
});
await fetchAndRender();
// Verify initial API calls (2 subsections in first section)
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(2);
// Clear axios history to track new calls
axiosMock.resetHistory();
// Update with full section scores and re-render
setTabData({ section_scores: mockSectionScores });
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
// Verify additional API calls for all subsections
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
});
it('should handle exam API errors gracefully without breaking ProgressTab', async () => {
// Clear existing mocks and setup specific error scenario
axiosMock.reset();
// Re-add base mocks
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
// Mock first exam to return 500 error
axiosMock.onGet(/.*exam1.*/).reply(500, { error: 'Server Error' });
// Mock other exams to succeed
axiosMock.onGet(/.*homework1.*/).reply(404, { customAttributes: { httpErrorStatus: 404 } });
axiosMock.onGet(/.*final_exam.*/).reply(200, {
exam: {
id: 2,
course_id: courseId,
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
exam_name: 'Final Exam',
attempt_status: 'ready_to_start',
time_remaining_seconds: 7200,
},
});
setTabData({ section_scores: mockSectionScores });
await fetchAndRender();
// Verify ProgressTab still renders successfully despite API error
expect(screen.getByText('Grades')).toBeInTheDocument();
// Verify the exam data includes error placeholder for failed request
const state = store.getState();
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData[0]).toEqual({}); // Failed request returns empty object
});
it('should use EXAMS_BASE_URL when configured for exam API calls', async () => {
// Configure EXAMS_BASE_URL
const originalConfig = getConfig();
setConfig({
...originalConfig,
EXAMS_BASE_URL: 'http://localhost:18740',
});
// Override mock to use new base URL
const examUrlWithExamsBase = /http:\/\/localhost:18740\/api\/v1\/student\/exam\/attempt\/course_id.*/;
axiosMock.onGet(examUrlWithExamsBase).reply(200, {
exam: {
id: 1,
course_id: courseId,
exam_name: 'Test Exam',
attempt_status: 'created',
},
});
setTabData({ section_scores: [mockSectionScores[0]] });
await fetchAndRender();
// Verify API calls use EXAMS_BASE_URL
const examApiCalls = axiosMock.history.get.filter(req => req.url.includes('localhost:18740'));
expect(examApiCalls.length).toBeGreaterThan(0);
// Restore original config
setConfig(originalConfig);
});
it('should extract sequence IDs correctly from nested section scores structure', async () => {
const complexSectionScores = [
{
display_name: 'Introduction',
subsections: [
{
assignment_type: 'Lecture',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
display_name: 'Course Introduction',
},
],
},
{
display_name: 'Assessments',
subsections: [
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
display_name: 'Quiz 1',
},
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
display_name: 'Quiz 2',
},
],
},
];
// Mock all the expected sequence IDs
const expectedSequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
];
expectedSequenceIds.forEach((sequenceId, index) => {
const examUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
axiosMock.onGet(examUrl).reply(index === 0 ? 404 : 200, {
exam: {
id: index,
course_id: courseId,
content_id: sequenceId,
exam_name: `Test ${index}`,
},
});
});
setTabData({ section_scores: complexSectionScores });
await fetchAndRender();
// Verify API calls were made for all extracted sequence IDs
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
// Verify correct sequence IDs were used in API calls
const apiCalls = axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'));
expectedSequenceIds.forEach(sequenceId => {
expect(apiCalls.some(call => call.url.includes(encodeURIComponent(sequenceId)))).toBe(true);
});
});
});
}); });

View File

@@ -187,7 +187,8 @@ const CertificateStatus = () => {
// regardless of passing or nonpassing status // regardless of passing or nonpassing status
if (!canViewCertificate) { if (!canViewCertificate) {
certCase = 'notAvailable'; certCase = 'notAvailable';
endDate = intl.formatDate(end, { // use the certificate_available_date if it is available, otherwise use the end date of the course
endDate = intl.formatDate((certificateAvailableDate || end), {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

@@ -7,18 +7,18 @@
.donut-chart-label { .donut-chart-label {
font: { font: {
family: $font-family-sans-serif; family: var(--pgn-typography-font-family-sans-serif);
size: .2rem; size: .2rem;
weight: $font-weight-normal; weight: var(--pgn-typography-font-weight-normal);
} }
text-anchor: middle; text-anchor: middle;
} }
.donut-chart-number { .donut-chart-number {
font: { font: {
family: $font-family-monospace; family: var(--pgn-typography-font-family-monospace);
size: .5rem; size: .5rem;
weight: $font-weight-bold; weight: var(--pgn-typography-font-weight-bold);
} }
line-height: 1rem; line-height: 1rem;
text-anchor: middle; text-anchor: middle;
@@ -29,7 +29,7 @@
} }
.donut-chart-text { .donut-chart-text {
fill: $primary-500; fill: var(--pgn-color-primary-500);
-moz-transform: translateY(0.25em); -moz-transform: translateY(0.25em);
-ms-transform: translateY(0.25em); -ms-transform: translateY(0.25em);
-webkit-transform: translateY(0.25em); -webkit-transform: translateY(0.25em);
@@ -56,7 +56,7 @@
.donut-ring, .donut-segment, .donut-hole { .donut-ring, .donut-segment, .donut-hole {
&.complete-stroke { &.complete-stroke {
stroke: $info-500; stroke: var(--pgn-color-info-500);
} }
&.divider-stroke { &.divider-stroke {
@@ -65,10 +65,10 @@
} }
&.incomplete-stroke { &.incomplete-stroke {
stroke: $light-300; stroke: var(--pgn-color-light-300);
} }
&.locked-stroke { &.locked-stroke {
stroke: $primary-500; stroke: var(--pgn-color-primary-500);
} }
} }

View File

@@ -8,26 +8,57 @@ import { useModel } from '../../../../generic/model-store';
import GradeRangeTooltip from './GradeRangeTooltip'; import GradeRangeTooltip from './GradeRangeTooltip';
import messages from '../messages'; import messages from '../messages';
import { getLatestDueDateInFuture } from '../../utils';
const ResponsiveText = ({
wideScreen, children, hasLetterGrades, passingGrade,
}) => {
const className = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
const iconSize = wideScreen ? 'h3' : 'h4';
return (
<span className={className}>
{children}
{hasLetterGrades && (
<span style={{ whiteSpace: 'nowrap' }}>
&nbsp;
<GradeRangeTooltip iconButtonClassName={iconSize} passingGrade={passingGrade} />
</span>
)}
</span>
);
};
const NoticeRow = ({
wideScreen, icon, bgClass, message,
}) => {
const textClass = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
return (
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${bgClass}`}>
<div className="col-auto p-0">{icon}</div>
<div className="col-11 pl-2 px-0">
<span className={textClass}>{message}</span>
</div>
</div>
);
};
const CourseGradeFooter = ({ passingGrade }) => { const CourseGradeFooter = ({ passingGrade }) => {
const intl = useIntl(); const intl = useIntl();
const courseId = useContextId(); const courseId = useContextId();
const { const {
courseGrade: { assignmentTypeGradeSummary,
isPassing, courseGrade: { isPassing, letterGrade },
letterGrade, gradingPolicy: { gradeRange },
},
gradingPolicy: {
gradeRange,
},
} = useModel('progress', courseId); } = useModel('progress', courseId);
const latestDueDate = getLatestDueDateInFuture(assignmentTypeGradeSummary);
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
const hasLetterGrades = Object.keys(gradeRange).length > 1;
const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key // build footer text
let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade }); let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade });
if (isPassing) { if (isPassing) {
if (hasLetterGrades) { if (hasLetterGrades) {
const minGradeRangeCutoff = gradeRange[letterGrade] * 100; const minGradeRangeCutoff = gradeRange[letterGrade] * 100;
@@ -47,42 +78,63 @@ const CourseGradeFooter = ({ passingGrade }) => {
} }
} }
const icon = isPassing ? <Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" /> const passingIcon = isPassing ? (
: <Icon src={WarningFilled} className="d-inline-flex align-bottom" />; <Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
) : (
<Icon src={WarningFilled} className="d-inline-flex align-bottom" />
);
return ( return (
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${isPassing ? 'bg-success-100' : 'bg-warning-100'}`}> <div>
<div className="col-auto p-0"> <NoticeRow
{icon} wideScreen={wideScreen}
</div> icon={passingIcon}
<div className="col-11 pl-2 px-0"> bgClass={isPassing ? 'bg-success-100' : 'bg-warning-100'}
{!wideScreen && ( message={(
<span className="h5 align-bottom"> <ResponsiveText
wideScreen={wideScreen}
hasLetterGrades={hasLetterGrades}
passingGrade={passingGrade}
>
{footerText} {footerText}
{hasLetterGrades && ( </ResponsiveText>
<span style={{ whiteSpace: 'nowrap' }}>
&nbsp;
<GradeRangeTooltip iconButtonClassName="h4" passingGrade={passingGrade} />
</span>
)}
</span>
)} )}
{wideScreen && ( />
<span className="h4 m-0 align-bottom"> {latestDueDate && (
{footerText} <NoticeRow
{hasLetterGrades && ( wideScreen={wideScreen}
<span style={{ whiteSpace: 'nowrap' }}> icon={<Icon src={WarningFilled} className="d-inline-flex align-bottom" />}
&nbsp; bgClass="bg-warning-100"
<GradeRangeTooltip iconButtonClassName="h3" passingGrade={passingGrade} /> message={intl.formatMessage(messages.courseGradeFooterDueDateNotice, {
</span> dueDate: intl.formatDate(latestDueDate, {
)} year: 'numeric',
</span> month: 'long',
)} day: 'numeric',
</div> hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
}),
})}
/>
)}
</div> </div>
); );
}; };
ResponsiveText.propTypes = {
wideScreen: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
hasLetterGrades: PropTypes.bool.isRequired,
passingGrade: PropTypes.number.isRequired,
};
NoticeRow.propTypes = {
wideScreen: PropTypes.bool.isRequired,
icon: PropTypes.element.isRequired,
bgClass: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
};
CourseGradeFooter.propTypes = { CourseGradeFooter.propTypes = {
passingGrade: PropTypes.number.isRequired, passingGrade: PropTypes.number.isRequired,
}; };

View File

@@ -48,7 +48,7 @@ const CourseGradeHeader = () => {
previewText = intl.formatMessage(messages.courseGradePreviewUpgradeDeadlinePassedBody); previewText = intl.formatMessage(messages.courseGradePreviewUpgradeDeadlinePassedBody);
} }
return ( return (
<div className="row w-100 m-0 p-4 rounded-top bg-primary-500 text-white"> <div id="grade-course-header" className="row w-100 m-0 p-4 rounded-top bg-primary-500 text-white">
<div className={`col-12 ${verifiedMode ? 'col-md-9' : ''} p-0`}> <div className={`col-12 ${verifiedMode ? 'col-md-9' : ''} p-0`}>
<div className="row w-100 m-0 p-0"> <div className="row w-100 m-0 p-0">
<div className="col-1 p-0"> <div className="col-1 p-0">
@@ -71,7 +71,7 @@ const CourseGradeHeader = () => {
</div> </div>
{verifiedMode && ( {verifiedMode && (
<div className="col-12 col-md-3 mt-3 mt-md-0 p-0 align-self-center text-right"> <div className="col-12 col-md-3 mt-3 mt-md-0 p-0 align-self-center text-right">
<Button variant="brand" size="sm" href={verifiedMode.upgradeUrl} onClick={logUpgradeButtonClick}> <Button id="upgrade-button" variant="brand" size="sm" href={verifiedMode.upgradeUrl} onClick={logUpgradeButtonClick}>
{intl.formatMessage(messages.courseGradePreviewUpgradeButton)} {intl.formatMessage(messages.courseGradePreviewUpgradeButton)}
</Button> </Button>
</div> </div>

View File

@@ -13,6 +13,7 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
const courseId = useContextId(); const courseId = useContextId();
const { const {
assignmentTypeGradeSummary,
courseGrade: { courseGrade: {
isPassing, isPassing,
percent, percent,
@@ -25,6 +26,8 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
const isLocaleRtl = isRtl(getLocale()); const isLocaleRtl = isRtl(getLocale());
const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== 'none');
if (isLocaleRtl) { if (isLocaleRtl) {
currentGradeDirection = currentGrade < 50 ? '-' : ''; currentGradeDirection = currentGrade < 50 ? '-' : '';
} }
@@ -56,6 +59,15 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
> >
{intl.formatMessage(messages.currentGradeLabel)} {intl.formatMessage(messages.currentGradeLabel)}
</text> </text>
<text
className="x-small"
textAnchor={currentGrade < 50 ? 'start' : 'end'}
x={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`}
y="35px"
style={{ transform: `translateX(${currentGradeDirection}3.4em)` }}
>
{hasHiddenGrades ? ` + ${intl.formatMessage(messages.hiddenScoreLabel)}` : ''}
</text>
</> </>
); );
}; };

View File

@@ -4,24 +4,24 @@
} }
.grade-bar__base { .grade-bar__base {
fill: $light-300; fill: var(--pgn-color-light-300);
} }
.grade-bar__divider { .grade-bar__divider {
fill: $primary-500; fill: var(--pgn-color-primary-500);
width: 1px; width: 1px;
} }
.grade-bar--passing { .grade-bar--passing {
fill: $primary-500; fill: var(--pgn-color-primary-500);
} }
.grade-bar--current-passing { .grade-bar--current-passing {
fill: $success-500; fill: var(--pgn-color-success-500);
} }
.grade-bar--current-non-passing { .grade-bar--current-non-passing {
fill: $accent-b; fill: var(--pgn-color-accent-b);
} }
} }
@@ -31,22 +31,22 @@
#minimum-grade-tooltip { #minimum-grade-tooltip {
.arrow::after { .arrow::after {
border-bottom-color: $primary-500; border-bottom-color: var(--pgn-color-primary-500);
} }
} }
#passing-grade-tooltip { #passing-grade-tooltip {
background: $success-500; background: var(--pgn-color-success-500);
.arrow::after { .arrow::after {
border-top-color: $success-500; border-top-color: var(--pgn-color-success-500);
} }
} }
#non-passing-grade-tooltip { #non-passing-grade-tooltip {
background: $accent-b; background: var(--pgn-color-accent-b);
.arrow::after { .arrow::after {
border-top-color: $accent-b; border-top-color: var(--pgn-color-accent-b);
} }
} }

View File

@@ -10,14 +10,12 @@ const GradeSummary = () => {
const courseId = useContextId(); const courseId = useContextId();
const { const {
gradingPolicy: { assignmentTypeGradeSummary,
assignmentPolicies,
},
} = useModel('progress', courseId); } = useModel('progress', courseId);
const [allOfSomeAssignmentTypeIsLocked, setAllOfSomeAssignmentTypeIsLocked] = useState(false); const [allOfSomeAssignmentTypeIsLocked, setAllOfSomeAssignmentTypeIsLocked] = useState(false);
if (assignmentPolicies.length === 0) { if (assignmentTypeGradeSummary.length === 0) {
return null; return null;
} }

View File

@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n'; import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import { DataTable } from '@openedx/paragon'; import { DataTable } from '@openedx/paragon';
import { Lock } from '@openedx/paragon/icons';
import { useContextId } from '../../../../data/hooks'; import { useContextId } from '../../../../data/hooks';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -16,9 +17,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
const courseId = useContextId(); const courseId = useContextId();
const { const {
gradingPolicy: { assignmentTypeGradeSummary,
assignmentPolicies,
},
gradesFeatureIsFullyLocked, gradesFeatureIsFullyLocked,
sectionScores, sectionScores,
} = useModel('progress', courseId); } = useModel('progress', courseId);
@@ -55,7 +54,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
return false; return false;
}; };
const gradeSummaryData = assignmentPolicies.map((assignment) => { const gradeSummaryData = assignmentTypeGradeSummary.map((assignment) => {
const { const {
averageGrade, averageGrade,
numDroppable, numDroppable,
@@ -80,13 +79,24 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignmentType); const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignmentType);
const isLocaleRtl = isRtl(getLocale()); const isLocaleRtl = isRtl(getLocale());
let weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`;
let gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`;
if (assignment.hasHiddenContribution === 'all') {
gradeDisplay = <Lock data-testid="lock-icon" />;
weightedGradeDisplay = <Lock data-testid="lock-icon" />;
} else if (assignment.hasHiddenContribution === 'some') {
gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`;
weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`;
}
return { return {
type: { type: {
footnoteId, footnoteMarker, type: assignmentType, locked, footnoteId, footnoteMarker, type: assignmentType, locked,
}, },
weight: { weight: `${(weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, weight: { weight: `${(weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
grade: { grade: `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, grade: { grade: gradeDisplay, locked },
weightedGrade: { weightedGrade: `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, weightedGrade: { weightedGrade: weightedGradeDisplay, locked },
}; };
}); });
const getAssignmentTypeCell = (value) => ( const getAssignmentTypeCell = (value) => (
@@ -102,6 +112,16 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
return ( return (
<> <>
<ul className="micro mb-3 pl-3 text-gray-700">
<li>
<b>{intl.formatMessage(messages.hiddenScoreLabel)}: </b>
{intl.formatMessage(messages.hiddenScoreInfoText)}
</li>
<li>
<b><Lock style={{ height: '15px' }} />: </b>
{` ${intl.formatMessage(messages.hiddenScoreLockInfoText)}`}
</li>
</ul>
<DataTable <DataTable
data={gradeSummaryData} data={gradeSummaryData}
itemCount={gradeSummaryData.length} itemCount={gradeSummaryData.length}

View File

@@ -1,9 +1,6 @@
import { useContext } from 'react';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n'; import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import { import {
DataTable, DataTable,
DataTableContext,
Icon, Icon,
OverlayTrigger, OverlayTrigger,
Stack, Stack,
@@ -17,18 +14,6 @@ import messages from '../messages';
const GradeSummaryTableFooter = () => { const GradeSummaryTableFooter = () => {
const intl = useIntl(); const intl = useIntl();
const { data } = useContext(DataTableContext);
const rawGrade = data.reduce(
(grade, currentValue) => {
const { weightedGrade } = currentValue.weightedGrade;
const percent = weightedGrade.replace(/%/g, '').trim();
return grade + parseFloat(percent);
},
0,
).toFixed(2);
const courseId = useContextId(); const courseId = useContextId();
const { const {
@@ -36,8 +21,16 @@ const GradeSummaryTableFooter = () => {
isPassing, isPassing,
percent, percent,
}, },
finalGrades,
} = useModel('progress', courseId); } = useModel('progress', courseId);
const getGradePercent = (grade) => {
const percentage = grade * 100;
return Number.isInteger(percentage) ? percentage.toFixed(0) : percentage.toFixed(2);
};
const rawGrade = getGradePercent(finalGrades);
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100'; const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
const totalGrade = (percent * 100).toFixed(0); const totalGrade = (percent * 100).toFixed(0);

View File

@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.', defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.',
description: 'Alt text for the grade chart bar', description: 'Alt text for the grade chart bar',
}, },
courseGradeFooterDueDateNotice: {
id: 'progress.courseGrade.footer.dueDateNotice',
defaultMessage: 'Some assignment scores are not yet included in your total grade. These grades will be released by {dueDate}.',
description: 'This is shown when there are pending assignments with a due date in the future',
},
courseGradeFooterGenericPassing: { courseGradeFooterGenericPassing: {
id: 'progress.courseGrade.footer.generic.passing', id: 'progress.courseGrade.footer.generic.passing',
defaultMessage: 'Youre currently passing this course', defaultMessage: 'Youre currently passing this course',
@@ -148,6 +153,21 @@ const messages = defineMessages({
+ "Your weighted grade is what's used to determine if you pass the course.", + "Your weighted grade is what's used to determine if you pass the course.",
description: 'The content of (tip box) for the grade summary section', description: 'The content of (tip box) for the grade summary section',
}, },
hiddenScoreLabel: {
id: 'progress.hiddenScoreLabel',
defaultMessage: 'Hidden Scores',
description: 'Text to indicate that some scores are hidden',
},
hiddenScoreInfoText: {
id: 'progress.hiddenScoreInfoText',
defaultMessage: 'Scores from assignments that count toward your final grade but some are not shown here.',
description: 'Information text about hidden score label',
},
hiddenScoreLockInfoText: {
id: 'progress.hiddenScoreLockInfoText',
defaultMessage: 'Scores for an assignment type are hidden but still counted toward the course grade.',
description: 'Information text about hidden score label when learners have limited access to grades feature',
},
noAccessToAssignmentType: { noAccessToAssignmentType: {
id: 'progress.noAcessToAssignmentType', id: 'progress.noAcessToAssignmentType',
defaultMessage: 'You do not have access to assignments of type {assignmentType}', defaultMessage: 'You do not have access to assignments of type {assignmentType}',

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { fetchExamAttemptsData } from '../data/thunks';
export function useGetExamsData(courseId, sequenceIds) {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchExamAttemptsData(courseId, sequenceIds));
}, [dispatch, courseId, sequenceIds]);
}

View File

@@ -0,0 +1,168 @@
import { renderHook } from '@testing-library/react';
import { useDispatch } from 'react-redux';
import { useGetExamsData } from './hooks';
import { fetchExamAttemptsData } from '../data/thunks';
// Mock the dependencies
jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
}));
jest.mock('../data/thunks', () => ({
fetchExamAttemptsData: jest.fn(),
}));
describe('useGetExamsData hook', () => {
const mockDispatch = jest.fn();
const mockFetchExamAttemptsData = jest.fn();
beforeEach(() => {
useDispatch.mockReturnValue(mockDispatch);
fetchExamAttemptsData.mockReturnValue(mockFetchExamAttemptsData);
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should dispatch fetchExamAttemptsData on mount', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
];
renderHook(() => useGetExamsData(courseId, sequenceIds));
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, sequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should re-dispatch when courseId changes', () => {
const initialCourseId = 'course-v1:edX+DemoX+Demo_Course';
const newCourseId = 'course-v1:edX+NewCourse+Demo';
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
const { rerender } = renderHook(
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
{
initialProps: { courseId: initialCourseId, sequenceIds },
},
);
// Verify initial call
expect(fetchExamAttemptsData).toHaveBeenCalledWith(initialCourseId, sequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
// Clear mocks to isolate the re-render call
jest.clearAllMocks();
// Re-render with new courseId
rerender({ courseId: newCourseId, sequenceIds });
expect(fetchExamAttemptsData).toHaveBeenCalledWith(newCourseId, sequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should re-dispatch when sequenceIds changes', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const initialSequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
const newSequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
];
const { rerender } = renderHook(
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
{
initialProps: { courseId, sequenceIds: initialSequenceIds },
},
);
// Verify initial call
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, initialSequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
// Clear mocks to isolate the re-render call
jest.clearAllMocks();
// Re-render with new sequenceIds
rerender({ courseId, sequenceIds: newSequenceIds });
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, newSequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should not re-dispatch when neither courseId nor sequenceIds changes', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
const { rerender } = renderHook(
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
{
initialProps: { courseId, sequenceIds },
},
);
// Verify initial call
expect(fetchExamAttemptsData).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledTimes(1);
// Clear mocks to isolate the re-render call
jest.clearAllMocks();
// Re-render with same props
rerender({ courseId, sequenceIds });
// Should not dispatch again
expect(fetchExamAttemptsData).not.toHaveBeenCalled();
expect(mockDispatch).not.toHaveBeenCalled();
});
it('should handle empty sequenceIds array', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceIds = [];
renderHook(() => useGetExamsData(courseId, sequenceIds));
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, []);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should handle null/undefined courseId', () => {
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
renderHook(() => useGetExamsData(null, sequenceIds));
expect(fetchExamAttemptsData).toHaveBeenCalledWith(null, sequenceIds);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
it('should handle sequenceIds reference change but same content', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const sequenceIds1 = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
const sequenceIds2 = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345']; // Same content, different reference
const { rerender } = renderHook(
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
{
initialProps: { courseId, sequenceIds: sequenceIds1 },
},
);
// Verify initial call
expect(fetchExamAttemptsData).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledTimes(1);
// Clear mocks to isolate the re-render call
jest.clearAllMocks();
// Re-render with different reference but same content
rerender({ courseId, sequenceIds: sequenceIds2 });
// Should dispatch again because the reference changed (useEffect dependency)
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, sequenceIds2);
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
});
});

View File

@@ -5,3 +5,15 @@ export const showUngradedAssignments = () => (
getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === 'true' getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === 'true'
|| getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true || getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true
); );
export const getLatestDueDateInFuture = (assignmentTypeGradeSummary) => {
let latest = null;
assignmentTypeGradeSummary.forEach((assignment) => {
const assignmentLastGradePublishDate = assignment.lastGradePublishDate;
if (assignmentLastGradePublishDate && (!latest || new Date(assignmentLastGradePublishDate) > new Date(latest))
&& new Date(assignmentLastGradePublishDate) > new Date()) {
latest = assignmentLastGradePublishDate;
}
});
return latest;
};

View File

@@ -34,7 +34,7 @@ const UpgradeToCompleteAlert = ({ logUpgradeLinkClick }) => {
} }
return ( return (
<Alert className="bg-light-200"> <Alert id="upgrade-complete-alert" className="bg-light-200">
<Row className="w-100 m-0"> <Row className="w-100 m-0">
<Col xs={12} md={9} className="small p-0 pr-md-2"> <Col xs={12} md={9} className="small p-0 pr-md-2">
<Alert.Heading>{intl.formatMessage(messages.upgradeToCompleteHeader)}</Alert.Heading> <Alert.Heading>{intl.formatMessage(messages.upgradeToCompleteHeader)}</Alert.Heading>

View File

@@ -36,7 +36,7 @@ const UpgradeToShiftDatesAlert = ({ logUpgradeLinkClick, model }) => {
} }
return ( return (
<Alert className="bg-light-200"> <Alert id="upgrade-shift-dates-alert" className="bg-light-200">
<Row className="w-100 m-0"> <Row className="w-100 m-0">
<Col xs={12} md={9} className="small p-0 pr-md-2"> <Col xs={12} md={9} className="small p-0 pr-md-2">
<strong>{intl.formatMessage(messages.missedDeadlines)}</strong> <strong>{intl.formatMessage(messages.missedDeadlines)}</strong>

View File

@@ -0,0 +1,20 @@
import classNames from 'classnames';
import React from 'react';
interface CourseTabLinkProps {
slug: string;
activeTabSlug?: string;
url: string;
title: string;
}
export const CourseTabLink = ({
slug, activeTabSlug, url, title,
}: CourseTabLinkProps) => (
<a
href={url}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
>
{title}
</a>
);

View File

@@ -0,0 +1,25 @@
import { CourseTabLink } from '@src/course-tabs/CourseTabLink';
import React from 'react';
interface CourseTabLinkListProps {
tabs: Array<{
title: string;
slug: string;
url: string;
}>,
activeTabSlug?: string;
}
export const CourseTabLinksList = ({ tabs, activeTabSlug }: CourseTabLinkListProps) => (
<>
{tabs.map(({ url, title, slug }) => (
<CourseTabLink
key={slug}
url={url}
slug={slug}
title={title}
activeTabSlug={activeTabSlug}
/>
))}
</>
);

View File

@@ -1,16 +1,28 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames'; import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages'; import { CourseTabLinksSlot } from '../plugin-slots/CourseTabLinksSlot';
import Tabs from '../generic/tabs/Tabs';
import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/courseware-search'; import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/courseware-search';
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks'; import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
import Tabs from '../generic/tabs/Tabs';
import messages from './messages';
interface CourseTabsNavigationProps {
activeTabSlug?: string;
className?: string | null;
tabs: Array<{
title: string;
slug: string;
url: string;
}>;
}
const CourseTabsNavigation = ({ const CourseTabsNavigation = ({
activeTabSlug, className, tabs, activeTabSlug = undefined,
}) => { className = null,
tabs,
}:CourseTabsNavigationProps) => {
const intl = useIntl(); const intl = useIntl();
const { show } = useCoursewareSearchState(); const { show } = useCoursewareSearchState();
@@ -23,15 +35,7 @@ const CourseTabsNavigation = ({
className="nav-underline-tabs" className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)} aria-label={intl.formatMessage(messages.courseMaterial)}
> >
{tabs.map(({ url, title, slug }) => ( <CourseTabLinksSlot tabs={tabs} activeTabSlug={activeTabSlug} />
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
</Tabs> </Tabs>
</div> </div>
<div className="search-toggle"> <div className="search-toggle">
@@ -44,19 +48,4 @@ const CourseTabsNavigation = ({
); );
}; };
CourseTabsNavigation.propTypes = {
activeTabSlug: PropTypes.string,
className: PropTypes.string,
tabs: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string.isRequired,
slug: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})).isRequired,
};
CourseTabsNavigation.defaultProps = {
activeTabSlug: undefined,
className: null,
};
export default CourseTabsNavigation; export default CourseTabsNavigation;

View File

@@ -5,13 +5,13 @@
.nav a, .nav a,
.nav button { .nav button {
&:hover { &:hover {
background-color: $light-400; background-color: var(--pgn-color-light-400);
} }
} }
.nav a { .nav a {
&:not(.active):hover { &:not(.active):hover {
background-color: $light-400; background-color: var(--pgn-color-light-400);
border-bottom: none; border-bottom: none;
} }
} }

View File

@@ -1,7 +1,7 @@
import { getConfig, history } from '@edx/frontend-platform'; import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom'; import { waitForElementToBeRemoved } from '@testing-library/dom';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
@@ -193,15 +193,13 @@ describe('CoursewareContainer', () => {
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseHomeMetadata.title); expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseHomeMetadata.title);
} }
function assertSequenceNavigation(container, expectedUnitCount = 3) { function assertNoSequenceNavigation(container) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button'); const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2); expect(sequenceNavButtons).toHaveLength(0);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous'); expect(container.querySelector('button, a')).not.toHaveTextContent('Previous');
// Prove this button is rendering an SVG tasks icon, meaning it's a unit/vertical. expect(container.querySelector('svg.fa-tasks')).toBeNull();
expect(sequenceNavButtons[1].querySelector('svg')).toHaveClass('fa-tasks'); expect(container.querySelector('button, a')).not.toHaveTextContent('Next');
expect(sequenceNavButtons[sequenceNavButtons.length - 1]).toHaveTextContent('Next');
} }
beforeEach(async () => { beforeEach(async () => {
@@ -224,7 +222,7 @@ describe('CoursewareContainer', () => {
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertNoSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
@@ -247,7 +245,7 @@ describe('CoursewareContainer', () => {
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertNoSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
@@ -274,29 +272,12 @@ describe('CoursewareContainer', () => {
setUpMockRequests({ courseBlocks }); setUpMockRequests({ courseBlocks });
}); });
// describe('when the URL contains a unit ID', () => {
// it('should ignore the section ID and redirect based on the unit ID', async () => {
// const urlUnit = unitTree[1][1][1];
// setUrl(sectionTree[1].id, urlUnit.id);
// const container = await loadContainer();
// assertLoadedHeader(container);
// assertSequenceNavigation(container, 2);
// assertLocation(container, sequenceTree[1][1].id, urlUnit.id);
// });
// it('should ignore invalid unit IDs and redirect to the course root', async () => {
// setUrl(sectionTree[1].id, 'foobar');
// await loadContainer();
// expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
// });
// });
describe('when the URL does not contain a unit ID', () => { describe('when the URL does not contain a unit ID', () => {
it('should choose a unit within the section\'s first sequence', async () => { it('should choose a unit within the section\'s first sequence', async () => {
setUrl(sectionTree[1].id); setUrl(sectionTree[1].id);
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container, 2); assertNoSequenceNavigation(container);
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id); assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
}); });
}); });
@@ -342,27 +323,6 @@ describe('CoursewareContainer', () => {
}); });
}); });
// describe('when the URL only contains a unit ID', () => {
// const { courseBlocks, unitTree, sequenceTree } = buildBinaryCourseBlocks(courseId, courseMetadata.name);
// beforeEach(async () => {
// setUpMockRequests({ courseBlocks });
// });
// it('should insert the sequence ID into the URL', async () => {
// const unit = unitTree[1][0][1];
// history.push(`/course/${courseId}/${unit.id}`);
// const container = await loadContainer();
// assertLoadedHeader(container);
// assertSequenceNavigation(container, 2);
// const expectedSequenceId = sequenceTree[1][0].id;
// const expectedUrl = `http://localhost/course/${courseId}/${expectedSequenceId}/${unit.id}`;
// expect(global.location.href).toEqual(expectedUrl);
// expect(container.querySelector('.fake-unit')).toHaveTextContent(unit.id);
// });
// });
describe('when the URL contains a course ID and sequence ID', () => { describe('when the URL contains a course ID and sequence ID', () => {
const sequenceBlock = defaultSequenceBlock; const sequenceBlock = defaultSequenceBlock;
const unitBlocks = defaultUnitBlocks; const unitBlocks = defaultUnitBlocks;
@@ -372,7 +332,7 @@ describe('CoursewareContainer', () => {
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertNoSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
@@ -391,7 +351,7 @@ describe('CoursewareContainer', () => {
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertNoSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
@@ -408,44 +368,24 @@ describe('CoursewareContainer', () => {
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container); assertNoSequenceNavigation(container);
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id); expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
}); });
it('should navigate between units and check block completion', async () => { it('should render the sequence_navigation plugin slot correctly', async () => {
axiosMock.onPost(`${courseId}/xblock/${sequenceBlock.id}/handler/get_completion`).reply(200, { axiosMock
complete: true, .onPost(`${courseId}/xblock/${sequenceBlock.id}/handler/get_completion`)
}); .reply(200, { complete: true });
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`); history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await loadContainer(); await loadContainer();
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button'); expect(screen.getByTestId('org.openedx.frontend.learning.sequence_navigation.v1')).toBeInTheDocument();
const sequenceNextButton = sequenceNavButtons[4];
expect(sequenceNextButton).toHaveTextContent('Next');
fireEvent.click(sequenceNextButton);
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
}); });
}); });
// describe('when the current sequence is an exam', () => {
// const { location } = window;
// beforeEach(() => {
// delete window.location;
// window.location = {
// assign: jest.fn(),
// };
// });
// afterEach(() => {
// window.location = location;
// });
// });
}); });
describe('when receiving a course_access error_code', () => { describe('when receiving a course_access error_code', () => {

View File

@@ -16,6 +16,7 @@ jest.mock('react-router-dom', () => ({
useLocation: () => ({ useLocation: () => ({
search: '?consentPath=/some-path', search: '?consentPath=/some-path',
}), }),
useSearchParams: () => [new URLSearchParams('?consentPath=/some-path'), () => {}],
})); }));
describe('RedirectPage component', () => { describe('RedirectPage component', () => {

View File

@@ -1,18 +1,20 @@
import PropTypes from 'prop-types';
import { import {
generatePath, useParams, useLocation, generatePath, useParams, useLocation, useSearchParams,
} from 'react-router-dom'; } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import queryString from 'query-string';
import { REDIRECT_MODES } from '../constants'; import { REDIRECT_MODES } from '../constants';
const RedirectPage = ({ interface Props {
pattern, mode, pattern: string;
}) => { mode: string;
}
const RedirectPage = ({ pattern = '', mode }: Props) => {
const { courseId } = useParams(); const { courseId } = useParams();
const location = useLocation(); const location = useLocation();
const { consentPath } = queryString.parse(location?.search); const [searchParams] = useSearchParams();
const consentPath = searchParams.get('consentPath') ?? '';
const { const {
LMS_BASE_URL, LMS_BASE_URL,
@@ -39,13 +41,4 @@ const RedirectPage = ({
return null; return null;
}; };
RedirectPage.propTypes = {
pattern: PropTypes.string,
mode: PropTypes.string.isRequired,
};
RedirectPage.defaultProps = {
pattern: null,
};
export default RedirectPage; export default RedirectPage;

View File

@@ -1,15 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { breakpoints, useWindowSize } from '@openedx/paragon'; import { breakpoints, useWindowSize } from '@openedx/paragon';
import { AlertList } from '@src/generic/user-messages'; import { AlertList } from '@src/generic/user-messages';
import { useModel } from '@src/generic/model-store'; import { useModel } from '@src/generic/model-store';
import { getCoursewareOutlineSidebarSettings } from '../data/selectors'; import { LearnerToolsSlot } from '../../plugin-slots/LearnerToolsSlot';
import Chat from './chat/Chat';
import SidebarProvider from './sidebar/SidebarContextProvider'; import SidebarProvider from './sidebar/SidebarContextProvider';
import NewSidebarProvider from './new-sidebar/SidebarContextProvider'; import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot'; import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot';
@@ -37,8 +36,6 @@ const Course = ({
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const sequence = useModel('sequences', sequenceId); const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null); const section = useModel('sections', sequence ? sequence.sectionId : null);
const { enableNavigationSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
const navigationDisabled = enableNavigationSidebar || (sequence?.navigationDisabled ?? false);
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -62,7 +59,7 @@ const Course = ({
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState( const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal, celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
); );
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth; const shouldDisplayLearnerTools = windowWidth >= breakpoints.medium.minWidth;
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek; const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
useEffect(() => { useEffect(() => {
@@ -84,28 +81,20 @@ const Course = ({
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title> <title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet> </Helmet>
<div className="position-relative d-flex align-items-xl-center mb-4 mt-1 flex-column flex-xl-row"> <div className="position-relative d-flex align-items-xl-center mb-4 mt-1 flex-column flex-xl-row">
{navigationDisabled || ( <CourseBreadcrumbsSlot
<> courseId={courseId}
<CourseBreadcrumbsSlot sectionId={section ? section.id : null}
courseId={courseId} sequenceId={sequenceId}
sectionId={section ? section.id : null} isStaff={isStaff}
sequenceId={sequenceId} unitId={unitId}
/>
{shouldDisplayLearnerTools && (
<LearnerToolsSlot
enrollmentMode={course.enrollmentMode}
isStaff={isStaff} isStaff={isStaff}
courseId={courseId}
unitId={unitId} unitId={unitId}
/> />
</>
)}
{shouldDisplayChat && (
<>
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
courseId={courseId}
contentToolsEnabled={course.showCalculator || course.notes.enabled}
unitId={unitId}
/>
</>
)} )}
<div className="w-100 d-flex align-items-center"> <div className="w-100 d-flex align-items-center">
<CourseOutlineMobileSidebarTriggerSlot /> <CourseOutlineMobileSidebarTriggerSlot />

View File

@@ -13,17 +13,25 @@ import Course from './Course';
import setupDiscussionSidebar from './test-utils'; import setupDiscussionSidebar from './test-utils';
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({ jest.mock('@edx/frontend-lib-special-exams', () => {
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'), const actual = jest.requireActual('@edx/frontend-lib-special-exams');
checkExamEntry: () => jest.fn(), return {
})); ...actual,
const mockChatTestId = 'fake-chat'; __esModule: true,
// Mock the default export (SequenceExamWrapper) to just render children
// eslint-disable-next-line react/prop-types
default: ({ children }) => <div data-testid="sequence-exam-wrapper">{children}</div>,
};
});
const mockLearnerToolsTestId = 'fake-learner-tools';
jest.mock( jest.mock(
'./chat/Chat', '../../plugin-slots/LearnerToolsSlot',
// eslint-disable-next-line react/prop-types () => ({
() => function ({ courseId }) { // eslint-disable-next-line react/prop-types
return <div className="fake-chat" data-testid={mockChatTestId}>Chat contents {courseId} </div>; LearnerToolsSlot({ courseId }) {
}, return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
},
}),
); );
const recordFirstSectionCelebration = jest.fn(); const recordFirstSectionCelebration = jest.fn();
@@ -183,26 +191,25 @@ describe('Course', () => {
const { rerender } = render(<Course {...testData} />, { store: testStore }); const { rerender } = render(<Course {...testData} />, { store: testStore });
loadUnit(); loadUnit();
waitFor(() => { const sidebar = await screen.findByTestId('sidebar-DISCUSSIONS');
expect(screen.findByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument(); expect(sidebar).toBeInTheDocument();
expect(screen.findByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none'); expect(sidebar).not.toHaveClass('d-none');
});
rerender(null); rerender(null);
}); });
it('handles click to open/close notification tray', async () => { it('handles click to open/close notification tray', async () => {
await setupDiscussionSidebar(); await setupDiscussionSidebar();
waitFor(() => { const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i }); expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument(); fireEvent.click(notificationShowButton);
fireEvent.click(notificationShowButton);
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument(); const notificationTray = await screen.findByRole('region', { name: /notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none'); expect(notificationTray).toBeInTheDocument();
}); expect(notificationTray).not.toHaveClass('d-none');
}); });
it('renders course breadcrumbs as expected', async () => { it('doesn\'t renders course breadcrumbs by default', async () => {
const courseMetadata = Factory.build('courseMetadata'); const courseMetadata = Factory.build('courseMetadata');
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build( const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
'block', 'block',
@@ -210,7 +217,7 @@ describe('Course', () => {
{ courseId: courseMetadata.id }, { courseId: courseMetadata.id },
)); ));
const testStore = await initializeTestStore({ const testStore = await initializeTestStore({
courseMetadata, unitBlocks, enableNavigationSidebar: { enable_navigation_sidebar: false }, courseMetadata, unitBlocks,
}, false); }, false);
const { courseware, models } = testStore.getState(); const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware; const { courseId, sequenceId } = courseware;
@@ -226,10 +233,10 @@ describe('Course', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument(); expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
}); });
// expect the section and sequence "titles" to be loaded in as breadcrumb labels. // expect the section and sequence "titles" not to be loaded in as breadcrumb labels.
waitFor(() => { await waitFor(() => {
expect(screen.findByText(Object.values(models.sections)[0].title)).toBeInTheDocument(); expect(screen.queryByText(Object.values(models.sections)[0].title)).not.toBeInTheDocument();
expect(screen.findByText(Object.values(models.sequences)[0].title)).toBeInTheDocument(); expect(screen.queryByText(Object.values(models.sequences)[0].title)).not.toBeInTheDocument();
}); });
}); });
@@ -360,28 +367,27 @@ describe('Course', () => {
}); });
}); });
it('displays chat when screen is wide enough (browser)', async () => { it('displays learner tools when screen is wide enough (browser)', async () => {
const courseMetadata = Factory.build('courseMetadata', { const courseMetadata = Factory.build('courseMetadata', {
learning_assistant_enabled: true,
enrollment: { mode: 'verified' }, enrollment: { mode: 'verified' },
}); });
const testStore = await initializeTestStore({ courseMetadata }, false); const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware } = testStore.getState(); const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware; const { courseId, sequenceId } = courseware;
const testData = { const testData = {
...mockData, ...mockData,
courseId, courseId,
sequenceId, sequenceId,
unitId: Object.values(models.units)[0].id,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId); const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
waitFor(() => expect(chat).toBeInTheDocument()); await waitFor(() => expect(learnerTools).toBeInTheDocument());
}); });
it('does not display chat when screen is too narrow (mobile)', async () => { it('does not display learner tools when screen is too narrow (mobile)', async () => {
global.innerWidth = breakpoints.extraSmall.minWidth; global.innerWidth = breakpoints.extraSmall.minWidth;
const courseMetadata = Factory.build('courseMetadata', { const courseMetadata = Factory.build('courseMetadata', {
learning_assistant_enabled: true,
enrollment: { mode: 'verified' }, enrollment: { mode: 'verified' },
}); });
const testStore = await initializeTestStore({ courseMetadata }, false); const testStore = await initializeTestStore({ courseMetadata }, false);
@@ -393,7 +399,7 @@ describe('Course', () => {
sequenceId, sequenceId,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId); const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
await expect(chat).not.toBeInTheDocument(); await expect(learnerTools).not.toBeInTheDocument();
}); });
}); });

View File

@@ -22,7 +22,6 @@
justify-content: center; justify-content: center;
button { button {
@extend .btn-primary;
font-size: 1.2rem; font-size: 1.2rem;
width: 50%; width: 50%;
} }

View File

@@ -1,82 +0,0 @@
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { getConfig } from '@edx/frontend-platform';
import { ALLOW_UPSELL_MODES, VERIFIED_MODES } from '@src/constants';
import { useModel } from '../../../generic/model-store';
const Chat = ({
enabled,
enrollmentMode,
isStaff,
courseId,
contentToolsEnabled,
unitId,
}) => {
const {
activeAttempt, exam,
} = useSelector(state => state.specialExams);
const course = useModel('coursewareMeta', courseId);
// If is disabled or taking an exam, we don't show the chat.
if (!enabled || activeAttempt?.attempt_id || exam?.id) { return null; }
// If is not staff and doesn't have an enrollment, we don't show the chat.
if (!isStaff && !enrollmentMode) { return null; }
const verifiedMode = VERIFIED_MODES.includes(enrollmentMode); // Enrollment verified
const auditMode = (
!isStaff
&& !verifiedMode
&& ALLOW_UPSELL_MODES.includes(enrollmentMode) // Can upgrade course
&& getConfig().ENABLE_XPERT_AUDIT
);
// If user has no access, we don't show the chat.
if (!isStaff && !(verifiedMode || auditMode)) { return null; }
// Date validation
const {
accessExpiration,
start,
end,
} = course;
const utcDate = (new Date()).toISOString();
const expiration = accessExpiration?.expirationDate || utcDate;
const validDate = (
(start ? start <= utcDate : true)
&& (end ? end >= utcDate : true)
&& (auditMode ? expiration >= utcDate : true)
);
// If date is invalid, we don't show the chat.
if (!validDate) { return null; }
// Use a portal to ensure that component overlay does not compete with learning MFE styles.
return createPortal(
<Xpert
courseId={courseId}
contentToolsEnabled={contentToolsEnabled}
unitId={unitId}
isUpgradeEligible={auditMode}
/>,
document.body,
);
};
Chat.propTypes = {
isStaff: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
contentToolsEnabled: PropTypes.bool.isRequired,
unitId: PropTypes.string.isRequired,
};
Chat.defaultProps = {
enrollmentMode: null,
};
export default Chat;

View File

@@ -1,286 +0,0 @@
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import {
initializeMockApp,
initializeTestStore,
render,
screen,
} from '../../../setupTest';
import Chat from './Chat';
// We do a partial mock to avoid mocking out other exported values (e.g. the reducer).
// We mock out the Xpert component, because the Xpert component has its own rules for whether it renders
// or not, and this includes the results of API calls it makes. We don't want to test those rules here, just
// whether the Xpert is rendered by the Chat component in certain conditions. Instead of actually rendering
// Xpert, we render and assert on a mocked component.
const mockXpertTestId = 'xpert';
jest.mock('@edx/frontend-lib-learning-assistant', () => {
const originalModule = jest.requireActual('@edx/frontend-lib-learning-assistant');
return {
__esModule: true,
...originalModule,
Xpert: () => (<div data-testid={mockXpertTestId}>mocked Xpert</div>),
};
});
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn().mockReturnValue({ ENABLE_XPERT_AUDIT: false }),
}));
initializeMockApp();
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let testCases = [];
let enabledTestCases = [];
let disabledTestCases = [];
const enabledModes = [
'professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education',
'paid-executive-education', 'paid-bootcamp',
];
const disabledModes = [null, undefined, 'xyz', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp'];
describe('Chat', () => {
let store;
beforeAll(async () => {
store = await initializeTestStore({
specialExams: {
activeAttempt: {
attempt_id: null,
},
exam: {
id: null,
},
},
});
});
// Generate test cases.
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
testCases = enabledTestCases.concat(disabledTestCases);
testCases.forEach(test => {
it(
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
async () => {
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
// Generate test cases.
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
testCases.forEach(test => {
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
});
});
// Generate the map function used for generating test cases by currying the map function.
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
// defining two separate map functions that differ in only one case, curry the function.
const generateMapFunction = (areEnabledModes) => (
(mode) => (
[
{
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
},
{
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
},
{
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
},
{
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
},
]
)
);
// Generate test cases.
enabledTestCases = enabledModes.map(generateMapFunction(true));
disabledTestCases = disabledModes.map(generateMapFunction(false));
testCases = enabledTestCases.concat(disabledTestCases);
testCases = testCases.flat();
testCases.forEach(test => {
it(
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
and ${test.enrollmentMode} enrollment mode`,
async () => {
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={test.isStaff}
enabled={test.enabled}
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
it('if course end date has passed, component should not be visible', async () => {
store = await initializeTestStore({
specialExams: {
activeAttempt: {
attempt_id: 1,
},
},
courseMetadata: Factory.build('courseMetadata', {
start: '2014-02-03T05:00:00Z',
end: '2014-02-05T05:00:00Z',
}),
});
render(
<BrowserRouter>
<Chat
enrollmentMode="verified"
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
expect(chat).not.toBeInTheDocument();
});
it('if learner has active exam attempt, component should not be visible', async () => {
store = await initializeTestStore({
specialExams: {
activeAttempt: {
attempt_id: 1,
},
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode="verified"
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
expect(chat).toBeInTheDocument();
});
it('displays component for audit learner if explicitly enabled', async () => {
getConfig.mockImplementation(() => ({ ENABLE_XPERT_AUDIT: true }));
store = await initializeTestStore({
courseMetadata: Factory.build('courseMetadata', {
access_expiration: { expiration_date: '' },
}),
});
render(
<BrowserRouter>
<Chat
enrollmentMode="audit"
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
expect(chat).toBeInTheDocument();
});
it('does not display component for audit learner if access deadline has passed', async () => {
getConfig.mockImplementation(() => ({ ENABLE_XPERT_AUDIT: true }));
store = await initializeTestStore({
courseMetadata: Factory.build('courseMetadata', {
access_expiration: { expiration_date: '2014-02-03T05:00:00Z' },
}),
});
render(
<BrowserRouter>
<Chat
enrollmentMode="audit"
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId(mockXpertTestId);
expect(chat).not.toBeInTheDocument();
});
});

View File

@@ -1 +0,0 @@
export { default } from './Chat';

View File

@@ -149,7 +149,7 @@ const Calculator = () => {
/> />
</li> </li>
</ul> </ul>
<table className="table small"> <table className="pgn__data-table small">
<thead> <thead>
<tr> <tr>
<th scope="col"> <th scope="col">

View File

@@ -4,4 +4,19 @@
background-color: #f1f1f1; background-color: #f1f1f1;
box-shadow: 0 -1px 0 0 #ddd; box-shadow: 0 -1px 0 0 #ddd;
} }
table {
tr {
border-bottom: var(--pgn-size-border-width) solid var(--pgn-color-border);
}
thead tr {
border-bottom: calc(2 * var(--pgn-size-border-width)) solid var(--pgn-color-border);
border-top: var(--pgn-size-border-width) solid var(--pgn-color-border);
}
tbody tr {
vertical-align: top;
}
}
} }

View File

@@ -8,8 +8,8 @@
display: inline-block; display: inline-block;
position: relative; position: relative;
z-index: 2; z-index: 2;
background-color: #f1f1f1; background-color: #f1f1f1 !important;
border: solid 1px #ddd; border: solid 1px #ddd !important;
border-bottom: none; border-bottom: none;
border-top-left-radius: .3rem; border-top-left-radius: .3rem;
border-top-right-radius: .3rem; border-top-right-radius: .3rem;

View File

@@ -18,19 +18,19 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import CelebrationMobile from './assets/celebration_456x328.gif'; import CelebrationMobile from './assets/celebration_456x328.gif';
import CelebrationDesktop from './assets/celebration_750x540.gif'; import CelebrationDesktop from './assets/celebration_750x540.gif';
import certificate from '../../../generic/assets/edX_certificate.png'; import certificate from '../../../generic/assets/openedx_certificate.png';
import certificateLocked from '../../../generic/assets/edX_locked_certificate.png'; import certificateLocked from '../../../generic/assets/openedx_locked_certificate.png';
import { FormattedPricing } from '../../../generic/upgrade-button'; import { FormattedPricing } from '../../../generic/upgrade-button';
import messages from './messages'; import messages from './messages';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';
import { requestCert } from '../../../course-home/data/thunks'; import { requestCert } from '../../../course-home/data/thunks';
import ProgramCompletion from './ProgramCompletion'; import ProgramCompletion from './ProgramCompletion';
import DashboardFootnote from './DashboardFootnote';
import UpgradeFootnote from './UpgradeFootnote'; import UpgradeFootnote from './UpgradeFootnote';
import SocialIcons from '../../social-share/SocialIcons'; import SocialIcons from '../../social-share/SocialIcons';
import { logClick, logVisit } from './utils'; import { logClick, logVisit } from './utils';
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links'; import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
import CourseRecommendationsSlot from '../../../plugin-slots/CourseRecommendationsSlot'; import DashboardFootnote from './DashboardFootnote';
import { CourseRecommendationsSlot } from '../../../plugin-slots/CourseExitPluginSlots';
const LINKEDIN_BLUE = '#2867B2'; const LINKEDIN_BLUE = '#2867B2';

View File

@@ -1,8 +1,5 @@
import React, { useEffect } from 'react'; import { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
@@ -10,13 +7,12 @@ import CourseCelebration from './CourseCelebration';
import CourseInProgress from './CourseInProgress'; import CourseInProgress from './CourseInProgress';
import CourseNonPassing from './CourseNonPassing'; import CourseNonPassing from './CourseNonPassing';
import { COURSE_EXIT_MODES, getCourseExitMode } from './utils'; import { COURSE_EXIT_MODES, getCourseExitMode } from './utils';
import messages from './messages';
import { unsubscribeFromGoalReminders } from './data/thunks'; import { unsubscribeFromGoalReminders } from './data/thunks';
import { CourseExitViewCoursesPluginSlot } from '../../../plugin-slots/CourseExitPluginSlots';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';
const CourseExit = () => { const CourseExit = () => {
const intl = useIntl();
const { courseId } = useSelector(state => state.courseware); const { courseId } = useSelector(state => state.courseware);
const { const {
certificateData, certificateData,
@@ -64,14 +60,7 @@ const CourseExit = () => {
return ( return (
<> <>
<div className="row w-100 mt-2 mb-4 justify-content-end"> <CourseExitViewCoursesPluginSlot />
<Button
variant="outline-primary"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
>
{intl.formatMessage(messages.viewCoursesButton)}
</Button>
</div>
{body} {body}
</> </>
); );

View File

@@ -1,47 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons'; import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../generic/model-store';
import { DashboardFootnoteLinkPluginSlot } from '../../../plugin-slots/CourseExitPluginSlots';
import Footnote from './Footnote'; import Footnote from './Footnote';
import messages from './messages'; import messages from './messages';
import { logClick } from './utils';
const DashboardFootnote = ({ variant }) => { const DashboardFootnote = ({ variant }) => {
const intl = useIntl(); const intl = useIntl();
const { courseId } = useSelector(state => state.courseware); const dashboardLink = (<DashboardFootnoteLinkPluginSlot variant={variant} />);
const { org } = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser();
const dashboardLink = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
className="text-reset"
onClick={() => logClick(org, courseId, administrator, 'dashboard_footnote', { variant })}
>
{intl.formatMessage(messages.dashboardLink)}
</Hyperlink>
);
return ( return (
<Footnote <Footnote
icon={faCalendarAlt} icon={faCalendarAlt}
text={( text={intl.formatMessage(messages.dashboardInfo, { dashboardLink })}
<FormattedMessage
id="courseCelebration.dashboardInfo" // for historical reasons
defaultMessage="You can access this course and its materials on your {dashboardLink}."
description="Text that precedes link to learner's dashboard"
values={{ dashboardLink }}
/>
)}
/> />
); );
}; };

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const Footnote = ({ icon, text }) => ( const Footnote = ({ icon, text }) => (
<div className="row w-100 mx-0 my-4 justify-content-center"> <div id="celebration-footnote-wrapper" className="row w-100 mx-0 my-4 justify-content-center">
<p className="text-gray-700"> <p className="text-gray-700">
<FontAwesomeIcon icon={icon} style={{ width: '20px' }} />&nbsp; <FontAwesomeIcon icon={icon} style={{ width: '20px' }} />&nbsp;
{text} {text}

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Button, Hyperlink } from '@openedx/paragon'; import { Alert, Button, Hyperlink } from '@openedx/paragon';
import certImage from '../../../generic/assets/edX_certificate.png'; import certImage from '../../../generic/assets/openedx_certificate.png';
import messages from './messages'; import messages from './messages';
/** /**

View File

@@ -20,6 +20,7 @@ const UpgradeFootnote = ({ deadline, href }) => {
const upgradeLink = ( const upgradeLink = (
<Hyperlink <Hyperlink
id="upgrade-link"
style={{ textDecoration: 'underline' }} style={{ textDecoration: 'underline' }}
destination={href} destination={href}
className="text-reset" className="text-reset"

View File

@@ -76,6 +76,11 @@ const messages = defineMessages({
defaultMessage: 'Dashboard', defaultMessage: 'Dashboard',
description: 'Link to users dashboard', description: 'Link to users dashboard',
}, },
dashboardInfo: {
id: 'courseCelebration.dashboardInfo', // for historical reasons
defaultMessage: 'You can access this course and its materials on your {dashboardLink}.',
description: "Text that precedes link to learner's dashboard",
},
endOfCourseDescription: { endOfCourseDescription: {
id: 'courseExit.endOfCourseDescription', id: 'courseExit.endOfCourseDescription',
defaultMessage: 'Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.', defaultMessage: 'Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.',

View File

@@ -17,6 +17,7 @@ const CELEBRATION_STATUSES = [
'audit_passing', 'audit_passing',
'downloadable', 'downloadable',
'earned_but_not_available', 'earned_but_not_available',
'not_earned_but_available_date',
'honor_passing', 'honor_passing',
'requesting', 'requesting',
'unverified', 'unverified',

View File

@@ -26,8 +26,8 @@ const SidebarProvider: React.FC<Props> = ({
const { verifiedMode } = useModel('courseHomeMeta', courseId); const { verifiedMode } = useModel('courseHomeMeta', courseId);
const topic = useModel('discussionTopics', unitId); const topic = useModel('discussionTopics', unitId);
const windowWidth = useWindowSize().width ?? window.innerWidth; const windowWidth = useWindowSize().width ?? window.innerWidth;
const shouldDisplayFullScreen = windowWidth < breakpoints.large.minWidth; const shouldDisplayFullScreen = windowWidth < breakpoints.large.minWidth!;
const shouldDisplaySidebarOpen = windowWidth > breakpoints.medium.minWidth; const shouldDisplaySidebarOpen = windowWidth > breakpoints.medium.minWidth!;
const query = new URLSearchParams(window.location.search); const query = new URLSearchParams(window.location.search);
const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true'; const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true';
const sidebarKey = `sidebar.${courseId}`; const sidebarKey = `sidebar.${courseId}`;
@@ -54,6 +54,8 @@ const SidebarProvider: React.FC<Props> = ({
}, [courseId]); }, [courseId]);
useEffect(() => { useEffect(() => {
window.sessionStorage.setItem('hideCourseOutlineSidebar', 'true');
window.sessionStorage.setItem(`notificationTrayStatus.${courseId}`, 'open');
setHideDiscussionbar(!isDiscussionbarAvailable); setHideDiscussionbar(!isDiscussionbarAvailable);
setHideNotificationbar(!isNotificationbarAvailable); setHideNotificationbar(!isNotificationbarAvailable);
if (initialSidebar && currentSidebar !== initialSidebar) { if (initialSidebar && currentSidebar !== initialSidebar) {

View File

@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getSessionStorage, setSessionStorage } from '../../../../../../data/sessionStorage';
import { import {
initializeMockApp, initializeTestStore, render, screen, initializeMockApp, initializeTestStore, render, screen,
} from '../../../../../../setupTest'; } from '../../../../../../setupTest';
@@ -14,11 +16,19 @@ import { buildTopicsFromUnits } from '../../../../../data/__factories__/discussi
import { getCourseDiscussionTopics } from '../../../../../data/thunks'; import { getCourseDiscussionTopics } from '../../../../../data/thunks';
import SidebarContext from '../../../SidebarContext'; import SidebarContext from '../../../SidebarContext';
import DiscussionsNotificationsSidebar from '../DiscussionsNotificationsSidebar'; import DiscussionsNotificationsSidebar from '../DiscussionsNotificationsSidebar';
import DiscussionsNotificationsTrigger from '../DiscussionsNotificationsTrigger';
import DiscussionsWidget from './DiscussionsWidget'; import DiscussionsWidget from './DiscussionsWidget';
initializeMockApp(); initializeMockApp();
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
jest.mock('../../../../../../data/sessionStorage', () => ({
getSessionStorage: jest.fn(),
setSessionStorage: jest.fn(),
}));
const onClickMock = jest.fn();
describe('DiscussionsWidget', () => { describe('DiscussionsWidget', () => {
let axiosMock; let axiosMock;
let mockData; let mockData;
@@ -81,4 +91,34 @@ describe('DiscussionsWidget', () => {
expect(screen.queryByText('Back to course')).toBeInTheDocument(); expect(screen.queryByText('Back to course')).toBeInTheDocument();
expect(sendTrackEvent).toHaveBeenCalledTimes(1); expect(sendTrackEvent).toHaveBeenCalledTimes(1);
}); });
it('should open notification tray if closed', () => {
(getSessionStorage as jest.Mock).mockReturnValue('closed');
renderWithProvider(() => <DiscussionsNotificationsTrigger onClick={onClickMock} />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(setSessionStorage).toHaveBeenCalledWith(
`notificationTrayStatus.${courseId}`,
'open',
);
expect(onClickMock).toHaveBeenCalled();
});
it('should close notification tray if open', () => {
(getSessionStorage as jest.Mock).mockReturnValue('open');
renderWithProvider(() => <DiscussionsNotificationsTrigger onClick={onClickMock} />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(setSessionStorage).toHaveBeenCalledWith(
`notificationTrayStatus.${courseId}`,
'open',
);
expect(onClickMock).toHaveBeenCalled();
});
}); });

View File

@@ -44,7 +44,7 @@ describe('NotificationsWidget', () => {
} }
beforeEach(async () => { beforeEach(async () => {
global.innerWidth = breakpoints.large.minWidth; global.innerWidth = breakpoints.large.minWidth!;
store = initializeStore(); store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);

View File

@@ -13,17 +13,17 @@ import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import PageLoading from '@src/generic/PageLoading'; import PageLoading from '@src/generic/PageLoading';
import { useModel } from '@src/generic/model-store'; import { useModel } from '@src/generic/model-store';
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '@src/alerts/sequence-alerts/hooks'; import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '@src/alerts/sequence-alerts/hooks';
import SequenceContainerSlot from '../../../plugin-slots/SequenceContainerSlot'; import SequenceContainerSlot from '@src/plugin-slots/SequenceContainerSlot';
import { CourseOutlineSidebarSlot } from '@src/plugin-slots/CourseOutlineSidebarSlot';
import { CourseOutlineSidebarTriggerSlot } from '@src/plugin-slots/CourseOutlineSidebarTriggerSlot';
import { NotificationsDiscussionsSidebarSlot } from '@src/plugin-slots/NotificationsDiscussionsSidebarSlot';
import SequenceNavigationSlot from '@src/plugin-slots/SequenceNavigationSlot';
import { getCoursewareOutlineSidebarSettings } from '../../data/selectors';
import CourseLicense from '../course-license'; import CourseLicense from '../course-license';
import { NotificationsDiscussionsSidebarSlot } from '../../../plugin-slots/NotificationsDiscussionsSidebarSlot';
import messages from './messages'; import messages from './messages';
import HiddenAfterDue from './hidden-after-due'; import HiddenAfterDue from './hidden-after-due';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation'; import { UnitNavigation } from './sequence-navigation';
import SequenceContent from './SequenceContent'; import SequenceContent from './SequenceContent';
import { CourseOutlineSidebarSlot } from '../../../plugin-slots/CourseOutlineSidebarSlot';
import { CourseOutlineSidebarTriggerSlot } from '../../../plugin-slots/CourseOutlineSidebarTriggerSlot';
const Sequence = ({ const Sequence = ({
unitId, unitId,
@@ -47,7 +47,7 @@ const Sequence = ({
const unit = useModel('units', unitId); const unit = useModel('units', unitId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit); const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
const { enableNavigationSidebar: isEnabledOutlineSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
const handleNext = () => { const handleNext = () => {
const nextIndex = sequence.unitIds.indexOf(unitId) + 1; const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
const newUnitId = sequence.unitIds[nextIndex]; const newUnitId = sequence.unitIds[nextIndex];
@@ -90,6 +90,30 @@ const Sequence = ({
sendTrackingLogEvent(eventName, payload); sendTrackingLogEvent(eventName, payload);
}; };
/* istanbul ignore next */
const nextHandler = () => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
};
/* istanbul ignore next */
const previousHandler = () => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
};
/* istanbul ignore next */
const onNavigate = (destinationUnitId) => {
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
};
const sequenceNavProps = {
nextHandler,
previousHandler,
onNavigate,
};
useSequenceBannerTextAlert(sequenceId); useSequenceBannerTextAlert(sequenceId);
useSequenceEntranceExamAlert(courseId, sequenceId, intl); useSequenceEntranceExamAlert(courseId, sequenceId, intl);
@@ -170,30 +194,25 @@ const Sequence = ({
/> />
<CourseOutlineSidebarSlot /> <CourseOutlineSidebarSlot />
<div className="sequence w-100"> <div className="sequence w-100">
{!isEnabledOutlineSidebar && ( <div className="sequence-navigation-container">
<div className="sequence-navigation-container"> {/**
<SequenceNavigation SequenceNavigationSlot renders nothing by default.
sequenceId={sequenceId} However, we still pass nextHandler, previousHandler, and onNavigate,
unitId={unitId} because, as per the slot's contract, if this slot is replaced
nextHandler={() => { with the default SequenceNavigation component, these props are required.
logEvent('edx.ui.lms.sequence.next_selected', 'top'); These handlers are excluded from test coverage via istanbul ignore,
handleNext(); since they are not used unless the slot is overridden.
}} */}
onNavigate={(destinationUnitId) => { <SequenceNavigationSlot
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId); sequenceId={sequenceId}
handleNavigate(destinationUnitId); unitId={unitId}
}} {...{
previousHandler={() => { ...sequenceNavProps,
logEvent('edx.ui.lms.sequence.previous_selected', 'top'); nextSequenceHandler,
handlePrevious(); handleNavigate,
}} }}
{...{ />
nextSequenceHandler, </div>
handleNavigate,
}}
/>
</div>
)}
<div className="unit-container flex-grow-1 pt-4"> <div className="unit-container flex-grow-1 pt-4">
<SequenceContent <SequenceContent
@@ -203,7 +222,6 @@ const Sequence = ({
unitId={unitId} unitId={unitId}
unitLoadedHandler={handleUnitLoaded} unitLoadedHandler={handleUnitLoaded}
isOriginalUserStaff={originalUserIsStaff} isOriginalUserStaff={originalUserIsStaff}
isEnabledOutlineSidebar={isEnabledOutlineSidebar}
renderUnitNavigation={renderUnitNavigation} renderUnitNavigation={renderUnitNavigation}
/> />
{unitHasLoaded && renderUnitNavigation(false)} {unitHasLoaded && renderUnitNavigation(false)}
@@ -217,18 +235,20 @@ const Sequence = ({
if (sequenceStatus === 'loaded') { if (sequenceStatus === 'loaded') {
return ( return (
<div> <>
<SequenceExamWrapper <div className="d-flex flex-column flex-grow-1 justify-content-center">
sequence={sequence} <SequenceExamWrapper
courseId={courseId} sequence={sequence}
isStaff={isStaff} courseId={courseId}
originalUserIsStaff={originalUserIsStaff} isStaff={isStaff}
canAccessProctoredExams={canAccessProctoredExams} originalUserIsStaff={originalUserIsStaff}
> canAccessProctoredExams={canAccessProctoredExams}
{defaultContent} >
</SequenceExamWrapper> {defaultContent}
</SequenceExamWrapper>
</div>
<CourseLicense license={license || undefined} /> <CourseLicense license={license || undefined} />
</div> </>
); );
} }

View File

@@ -24,7 +24,6 @@ describe('Sequence', () => {
{ type: 'vertical' }, { type: 'vertical' },
{ courseId: courseMetadata.id }, { courseId: courseMetadata.id },
)); ));
const enableNavigationSidebar = { enable_navigation_sidebar: false };
beforeAll(async () => { beforeAll(async () => {
const store = await initializeTestStore({ courseMetadata, unitBlocks }); const store = await initializeTestStore({ courseMetadata, unitBlocks });
@@ -96,7 +95,6 @@ describe('Sequence', () => {
unitBlocks, unitBlocks,
sequenceBlocks, sequenceBlocks,
sequenceMetadata, sequenceMetadata,
enableNavigationSidebar: { enable_navigation_sidebar: true },
}, false); }, false);
const { container } = render( const { container } = render(
<SidebarWrapper overrideData={{ sequenceId: sequenceBlocks[0].id }} />, <SidebarWrapper overrideData={{ sequenceId: sequenceBlocks[0].id }} />,
@@ -131,7 +129,7 @@ describe('Sequence', () => {
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] }, { courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
)]; )];
const testStore = await initializeTestStore({ const testStore = await initializeTestStore({
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata, enableNavigationSidebar, courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
}, false); }, false);
render( render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />, <Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
@@ -190,7 +188,7 @@ describe('Sequence', () => {
beforeAll(async () => { beforeAll(async () => {
testStore = await initializeTestStore({ testStore = await initializeTestStore({
courseMetadata, unitBlocks, sequenceBlocks, enableNavigationSidebar, courseMetadata, unitBlocks, sequenceBlocks,
}, false); }, false);
}); });
@@ -366,7 +364,6 @@ describe('Sequence', () => {
unitBlocks, unitBlocks,
sequenceBlocks: testSequenceBlocks, sequenceBlocks: testSequenceBlocks,
sequenceMetadata: testSequenceMetadata, sequenceMetadata: testSequenceMetadata,
enableNavigationSidebar,
}, false); }, false);
const testData = { const testData = {
...mockData, ...mockData,

View File

@@ -16,7 +16,6 @@ const SequenceContent = ({
unitId, unitId,
unitLoadedHandler, unitLoadedHandler,
isOriginalUserStaff, isOriginalUserStaff,
isEnabledOutlineSidebar,
renderUnitNavigation, renderUnitNavigation,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -63,7 +62,6 @@ const SequenceContent = ({
id={unitId} id={unitId}
onLoaded={unitLoadedHandler} onLoaded={unitLoadedHandler}
isOriginalUserStaff={isOriginalUserStaff} isOriginalUserStaff={isOriginalUserStaff}
isEnabledOutlineSidebar={isEnabledOutlineSidebar}
renderUnitNavigation={renderUnitNavigation} renderUnitNavigation={renderUnitNavigation}
/> />
); );
@@ -76,7 +74,6 @@ SequenceContent.propTypes = {
unitId: PropTypes.string, unitId: PropTypes.string,
unitLoadedHandler: PropTypes.func.isRequired, unitLoadedHandler: PropTypes.func.isRequired,
isOriginalUserStaff: PropTypes.bool.isRequired, isOriginalUserStaff: PropTypes.bool.isRequired,
isEnabledOutlineSidebar: PropTypes.bool.isRequired,
renderUnitNavigation: PropTypes.func.isRequired, renderUnitNavigation: PropTypes.func.isRequired,
}; };

View File

@@ -15,6 +15,7 @@ describe('Sequence Content', () => {
sequenceId: courseware.sequenceId, sequenceId: courseware.sequenceId,
unitId: models.sequences[courseware.sequenceId].unitIds[0], unitId: models.sequences[courseware.sequenceId].unitIds[0],
unitLoadedHandler: () => { }, unitLoadedHandler: () => { },
renderUnitNavigation: () => { },
}; };
}); });
@@ -38,7 +39,7 @@ describe('Sequence Content', () => {
}); });
it('displays message for no content', () => { it('displays message for no content', () => {
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true }); render(<SequenceContent {...mockData} unitId="" />, { wrapWithRouter: true });
expect(screen.getByText('There is no content here.')).toBeInTheDocument(); expect(screen.getByText('There is no content here.')).toBeInTheDocument();
}); });
}); });

View File

@@ -1,10 +1,8 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react'; import { ModalDialog } from '@openedx/paragon';
import { StrictDict } from '@edx/react-unit-test-utils';
import { ModalDialog, Modal } from '@openedx/paragon';
import { ContentIFrameLoaderSlot } from '../../../../plugin-slots/ContentIFrameLoaderSlot'; import { ContentIFrameLoaderSlot } from '../../../../plugin-slots/ContentIFrameLoaderSlot';
import { ContentIFrameErrorSlot } from '../../../../plugin-slots/ContentIFrameErrorSlot';
import * as hooks from './hooks'; import * as hooks from './hooks';
@@ -22,10 +20,10 @@ export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *; autoplay *' 'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *; autoplay *'
); );
export const testIDs = StrictDict({ export const testIDs = {
contentIFrame: 'content-iframe-test-id', contentIFrame: 'content-iframe-test-id',
modalIFrame: 'modal-iframe-test-id', modalIFrame: 'modal-iframe-test-id',
}); };
const ContentIFrame = ({ const ContentIFrame = ({
iframeUrl, iframeUrl,
@@ -65,54 +63,44 @@ const ContentIFrame = ({
onLoad: handleIFrameLoad, onLoad: handleIFrameLoad,
}; };
let modalContent;
if (modalOptions.isOpen) {
modalContent = modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
: (
<iframe
title={modalOptions.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{ width: '100%', height: modalOptions.height }}
/>
);
}
return ( return (
<> <>
{(shouldShowContent && !hasLoaded) && ( {(shouldShowContent && !hasLoaded) && (
showError ? <ErrorPage /> : <ContentIFrameLoaderSlot courseId={courseId} loadingMessage={loadingMessage} /> showError ? (
<ContentIFrameErrorSlot courseId={courseId} />
) : (
<ContentIFrameLoaderSlot courseId={courseId} loadingMessage={loadingMessage} />
)
)} )}
{shouldShowContent && ( {shouldShowContent && (
<div className="unit-iframe-wrapper"> <div className="unit-iframe-wrapper">
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} /> <iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
</div> </div>
)} )}
{modalOptions.isOpen && (modalOptions.isFullscreen {modalOptions.isOpen
? ( && (
<ModalDialog <ModalDialog
dialogClassName="modal-lti" dialogClassName="modal-lti"
onClose={handleModalClose} onClose={handleModalClose}
size="fullscreen" size={modalOptions.isFullscreen ? 'fullscreen' : 'md'}
isOpen isOpen
hasCloseButton={false} hasCloseButton={false}
> >
<ModalDialog.Body className={modalOptions.modalBodyClassName}> <ModalDialog.Body className={modalOptions.modalBodyClassName}>
{modalContent} {modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
: (
<iframe
title={modalOptions.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{ width: '100%', height: modalOptions.height }}
/>
)}
</ModalDialog.Body> </ModalDialog.Body>
</ModalDialog> </ModalDialog>
)}
) : (
<Modal
body={modalContent}
dialogClassName="modal-lti"
onClose={handleModalClose}
open
/>
)
)}
</> </>
); );
}; };

View File

@@ -1,26 +1,17 @@
import React from 'react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { ErrorPage } from '@edx/frontend-platform/react';
import { ModalDialog, Modal } from '@openedx/paragon';
import { shallow } from '@edx/react-unit-test-utils';
import PageLoading from '@src/generic/PageLoading';
import { ContentIFrameLoaderSlot } from '@src/plugin-slots/ContentIFrameLoaderSlot';
import * as hooks from './hooks'; import * as hooks from './hooks';
import ContentIFrame, { IFRAME_FEATURE_POLICY, testIDs } from './ContentIFrame'; import ContentIFrame, { IFRAME_FEATURE_POLICY } from './ContentIFrame';
jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: 'ErrorPage' })); // eslint-disable-next-line react/prop-types
const IntlWrapper = ({ children }) => (
<IntlProvider locale="en">{children}</IntlProvider>
);
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils') jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: () => <div>ErrorPage</div> }));
.mockComponents({
Modal: 'Modal',
ModalDialog: {
Body: 'ModalDialog.Body',
},
}));
jest.mock('@src/generic/PageLoading', () => 'PageLoading'); jest.mock('@src/generic/PageLoading', () => jest.fn(() => <div>PageLoading</div>));
jest.mock('./hooks', () => ({ jest.mock('./hooks', () => ({
useIFrameBehavior: jest.fn(), useIFrameBehavior: jest.fn(),
@@ -68,14 +59,13 @@ const props = {
title: 'test-title', title: 'test-title',
}; };
let el;
describe('ContentIFrame Component', () => { describe('ContentIFrame Component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('behavior', () => { describe('behavior', () => {
beforeEach(() => { beforeEach(() => {
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
}); });
it('initializes iframe behavior hook', () => { it('initializes iframe behavior hook', () => {
expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({ expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({
@@ -90,61 +80,61 @@ describe('ContentIFrame Component', () => {
}); });
}); });
describe('output', () => { describe('output', () => {
let component;
describe('if shouldShowContent', () => { describe('if shouldShowContent', () => {
describe('if not hasLoaded', () => { describe('if not hasLoaded', () => {
it('displays errorPage if showError', () => { it('displays errorPage if showError', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true }); hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
expect(el.instance.findByType(ErrorPage).length).toEqual(1); const errorPage = screen.getByText('ErrorPage');
expect(errorPage).toBeInTheDocument();
}); });
it('displays PageLoading component if not showError', () => { it('displays PageLoading component if not showError', () => {
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
[component] = el.instance.findByType(ContentIFrameLoaderSlot); const pageLoading = screen.getByText('PageLoading');
expect(component.props.loadingMessage).toEqual(props.loadingMessage); expect(pageLoading).toBeInTheDocument();
}); });
}); });
describe('hasLoaded', () => { describe('hasLoaded', () => {
it('does not display PageLoading or ErrorPage', () => { it('does not display PageLoading or ErrorPage', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true }); hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true });
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
expect(el.instance.findByType(PageLoading).length).toEqual(0); const pageLoading = screen.queryByText('PageLoading');
expect(el.instance.findByType(ErrorPage).length).toEqual(0); expect(pageLoading).toBeNull();
const errorPage = screen.queryByText('ErrorPage');
expect(errorPage).toBeNull();
}); });
}); });
it('display iframe with props from hooks', () => { it('display iframe with props from hooks', () => {
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
[component] = el.instance.findByTestId(testIDs.contentIFrame); const iframe = screen.getByTitle(props.title);
expect(component.props).toEqual({ expect(iframe).toBeInTheDocument();
allow: IFRAME_FEATURE_POLICY, expect(iframe).toHaveAttribute('id', props.elementId);
allowFullScreen: true, expect(iframe).toHaveAttribute('src', props.iframeUrl);
scrolling: 'no', expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
referrerPolicy: 'origin', expect(iframe).toHaveAttribute('allowfullscreen', '');
title: props.title, expect(iframe).toHaveAttribute('scrolling', 'no');
id: props.elementId, expect(iframe).toHaveAttribute('referrerpolicy', 'origin');
src: props.iframeUrl,
height: iframeBehavior.iframeHeight,
onLoad: iframeBehavior.handleIFrameLoad,
'data-testid': testIDs.contentIFrame,
});
}); });
}); });
describe('if not shouldShowContent', () => { describe('if not shouldShowContent', () => {
it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => { it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => {
el = shallow(<ContentIFrame {...{ ...props, shouldShowContent: false }} />); render(<ContentIFrame {...{ ...props, shouldShowContent: false }} />, { wrapper: IntlWrapper });
expect(el.instance.findByType(PageLoading).length).toEqual(0); expect(screen.queryByText('PageLoading')).toBeNull();
expect(el.instance.findByType(ErrorPage).length).toEqual(0); expect(screen.queryByText('ErrorPage')).toBeNull();
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0); expect(screen.queryByTitle(props.title)).toBeNull();
}); });
}); });
it('does not display modal if modalOptions returns isOpen: false', () => { it('does not display modal if modalOptions returns isOpen: false', () => {
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
expect(el.instance.findByType(Modal).length).toEqual(0); const modal = screen.queryByRole('dialog');
expect(modal).toBeNull();
}); });
describe('if modalOptions.isOpen', () => { describe('if modalOptions.isOpen', () => {
const testModalOpenAndHandleClose = () => { const testModalOpenAndHandleClose = () => {
test('Modal component isOpen, with handleModalClose from hook', () => { it('closes modal on close button click', () => {
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose); const closeButton = screen.getByTestId('modal-backdrop');
closeButton.click();
expect(modalIFrameData.handleModalClose).toHaveBeenCalled();
}); });
}; };
describe('fullscreen modal', () => { describe('fullscreen modal', () => {
@@ -154,14 +144,13 @@ describe('ContentIFrame Component', () => {
...modalIFrameData, ...modalIFrameData,
modalOptions: { ...modalOptions.withBody, isFullscreen: true }, modalOptions: { ...modalOptions.withBody, isFullscreen: true },
}); });
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
[component] = el.instance.findByType(ModalDialog);
}); });
it('displays Modal with div wrapping provided body content if modal.body is provided', () => { it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
const content = component.findByType(ModalDialog.Body)[0].children[0]; const dialog = screen.getByRole('dialog');
expect(content.matches(shallow( expect(dialog).toBeInTheDocument();
<div className="unit-modal">{modalOptions.withBody.body}</div>, const modalBody = screen.getByText(modalOptions.withBody.body);
))).toEqual(true); expect(modalBody).toBeInTheDocument();
}); });
testModalOpenAndHandleClose(); testModalOpenAndHandleClose();
}); });
@@ -172,53 +161,42 @@ describe('ContentIFrame Component', () => {
...modalIFrameData, ...modalIFrameData,
modalOptions: { ...modalOptions.withUrl, isFullscreen: true }, modalOptions: { ...modalOptions.withUrl, isFullscreen: true },
}); });
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
[component] = el.instance.findByType(ModalDialog); });
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
const iframe = screen.getByTitle(modalOptions.withUrl.title);
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('src', modalOptions.withUrl.url);
}); });
testModalOpenAndHandleClose(); testModalOpenAndHandleClose();
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
const content = component.findByType(ModalDialog.Body)[0].children[0];
expect(content.matches(shallow(
<iframe
title={modalOptions.withUrl.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.withUrl.url}
style={{ width: '100%', height: modalOptions.withUrl.height }}
/>,
))).toEqual(true);
});
}); });
}); });
describe('body modal', () => { describe('body modal', () => {
beforeEach(() => { beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody }); hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody });
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
[component] = el.instance.findByType(Modal);
}); });
it('displays Modal with div wrapping provided body content if modal.body is provided', () => { it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
expect(component.props.body).toEqual(<div className="unit-modal">{modalOptions.withBody.body}</div>); const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
const modalBody = screen.getByText(modalOptions.withBody.body);
expect(modalBody).toBeInTheDocument();
}); });
testModalOpenAndHandleClose(); testModalOpenAndHandleClose();
}); });
describe('url modal', () => { describe('url modal', () => {
beforeEach(() => { beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withUrl }); hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withUrl });
el = shallow(<ContentIFrame {...props} />); render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
[component] = el.instance.findByType(Modal); });
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
const iframe = screen.getByTitle(modalOptions.withUrl.title);
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('src', modalOptions.withUrl.url);
}); });
testModalOpenAndHandleClose(); testModalOpenAndHandleClose();
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
expect(component.props.body).toEqual(
<iframe
title={modalOptions.withUrl.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.withUrl.url}
style={{ width: '100%', height: modalOptions.withUrl.height }}
/>,
);
});
}); });
}); });
}); });

View File

@@ -1,22 +1,15 @@
import React from 'react'; import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
import { useModel } from '@src/generic/model-store'; import { useModel } from '@src/generic/model-store';
import PageLoading from '@src/generic/PageLoading';
import { GatedUnitContentMessageSlot } from '@src/plugin-slots/GatedUnitContentMessageSlot';
import messages from '../messages';
import HonorCode from '../honor-code';
import LockPaywall from '../lock-paywall';
import hooks from './hooks'; import hooks from './hooks';
import { modelKeys } from './constants'; import { modelKeys } from './constants';
import UnitSuspense from './UnitSuspense'; import UnitSuspense from './UnitSuspense';
jest.mock('@edx/frontend-platform/i18n', () => ({ jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
defineMessages: m => m, defineMessages: m => m,
useIntl: () => ({ formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage }),
})); }));
jest.mock('react', () => ({ jest.mock('react', () => ({
@@ -24,10 +17,9 @@ jest.mock('react', () => ({
Suspense: 'Suspense', Suspense: 'Suspense',
})); }));
jest.mock('../honor-code', () => 'HonorCode'); jest.mock('../honor-code', () => jest.fn(() => <div>HonorCode</div>));
jest.mock('../lock-paywall', () => 'LockPaywall'); jest.mock('../lock-paywall', () => jest.fn(() => <div>LockPaywall</div>));
jest.mock('@src/generic/model-store', () => ({ useModel: jest.fn() })); jest.mock('@src/generic/model-store', () => ({ useModel: jest.fn() }));
jest.mock('@src/generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({ jest.mock('./hooks', () => ({
useShouldDisplayHonorCode: jest.fn(() => false), useShouldDisplayHonorCode: jest.fn(() => false),
@@ -46,7 +38,6 @@ const props = {
id: 'test-id', id: 'test-id',
}; };
let el;
describe('UnitSuspense component', () => { describe('UnitSuspense component', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -54,7 +45,7 @@ describe('UnitSuspense component', () => {
}); });
describe('behavior', () => { describe('behavior', () => {
it('initializes models', () => { it('initializes models', () => {
el = shallow(<UnitSuspense {...props} />); render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
const { calls } = useModel.mock; const { calls } = useModel.mock;
const [unitCall] = calls.filter(call => call[0] === modelKeys.units); const [unitCall] = calls.filter(call => call[0] === modelKeys.units);
const [metaCall] = calls.filter(call => call[0] === modelKeys.coursewareMeta); const [metaCall] = calls.filter(call => call[0] === modelKeys.coursewareMeta);
@@ -66,8 +57,9 @@ describe('UnitSuspense component', () => {
describe('LockPaywall', () => { describe('LockPaywall', () => {
const testNoPaywall = () => { const testNoPaywall = () => {
it('does not display LockPaywall', () => { it('does not display LockPaywall', () => {
el = shallow(<UnitSuspense {...props} />); render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
expect(el.instance.findByType(LockPaywall).length).toEqual(0); const lockPaywall = screen.queryByText('LockPaywall');
expect(lockPaywall).toBeNull();
}); });
}; };
describe('gating not enabled', () => { testNoPaywall(); }); describe('gating not enabled', () => { testNoPaywall(); });
@@ -78,29 +70,29 @@ describe('UnitSuspense component', () => {
describe('gating enabled, gated content included', () => { describe('gating enabled, gated content included', () => {
beforeEach(() => { mockModels(true, true); }); beforeEach(() => { mockModels(true, true); });
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => { it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
el = shallow(<UnitSuspense {...props} />); hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false);
const [component] = el.instance.findByType(GatedUnitContentMessageSlot); render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
expect(component.parent.type).toEqual('Suspense'); const lockPaywall = screen.getByText('LockPaywall');
expect(component.parent.props.fallback) expect(lockPaywall).toBeInTheDocument();
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />); const suspenseWrapper = lockPaywall.closest('suspense');
expect(component.props.courseId).toEqual(props.courseId); expect(suspenseWrapper).toBeInTheDocument();
}); });
}); });
}); });
describe('HonorCode', () => { describe('HonorCode', () => {
it('does not display HonorCode if useShouldDisplayHonorCode => false', () => { it('does not display HonorCode if useShouldDisplayHonorCode => false', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false); hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false);
el = shallow(<UnitSuspense {...props} />); render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
expect(el.instance.findByType(HonorCode).length).toEqual(0); const honorCode = screen.queryByText('HonorCode');
expect(honorCode).toBeNull();
}); });
it('displays HonorCode component in Suspense wrapper with PageLoading fallback if shouldDisplayHonorCode', () => { it('displays HonorCode component in Suspense wrapper with PageLoading fallback if shouldDisplayHonorCode', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true); hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
el = shallow(<UnitSuspense {...props} />); render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
const [component] = el.instance.findByType(HonorCode); const honorCode = screen.getByText('HonorCode');
expect(component.parent.type).toEqual('Suspense'); expect(honorCode).toBeInTheDocument();
expect(component.parent.props.fallback) const suspenseWrapper = honorCode.closest('suspense');
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />); expect(suspenseWrapper).toBeInTheDocument();
expect(component.props.courseId).toEqual(props.courseId);
}); });
}); });
}); });

View File

@@ -1,26 +1,25 @@
import { StrictDict } from '@edx/react-unit-test-utils/dist'; export const modelKeys = {
export const modelKeys = StrictDict({
units: 'units', units: 'units',
coursewareMeta: 'coursewareMeta', coursewareMeta: 'coursewareMeta',
}); } as const;
export const views = StrictDict({ export const views = {
student: 'student_view', student: 'student_view',
public: 'public_view', public: 'public_view',
}); } as const;
export const loadingState = 'loading'; export const loadingState = 'loading';
export const messageTypes = StrictDict({ export const messageTypes = {
modal: 'plugin.modal', modal: 'plugin.modal',
resize: 'plugin.resize', resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen', videoFullScreen: 'plugin.videoFullScreen',
}); autoAdvance: 'plugin.autoAdvance',
} as const;
export default StrictDict({ export default {
modelKeys, modelKeys,
views, views,
loadingState, loadingState,
messageTypes, messageTypes,
}); };

View File

@@ -1,19 +1,13 @@
import React from 'react'; import React from 'react';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { useExamAccessToken, useFetchExamAccessToken, useIsExam } from '@edx/frontend-lib-special-exams'; import { useExamAccessToken, useFetchExamAccessToken, useIsExam } from '@edx/frontend-lib-special-exams';
export const stateKeys = StrictDict({
accessToken: 'accessToken',
blockAccess: 'blockAccess',
});
const useExamAccess = ({ const useExamAccess = ({
id, id,
}) => { }) => {
const isExam = useIsExam(); const isExam = useIsExam();
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam); const [blockAccess, setBlockAccess] = React.useState(isExam);
const fetchExamAccessToken = useFetchExamAccessToken(); const fetchExamAccessToken = useFetchExamAccessToken();

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { renderHook } from '@testing-library/react';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
@@ -9,10 +8,13 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { fetchCourse } from '@src/courseware/data'; import { fetchCourse } from '@src/courseware/data';
import { processEvent } from '@src/course-home/data/thunks'; import { processEvent } from '@src/course-home/data/thunks';
import { useEventListener } from '@src/generic/hooks'; import { useEventListener } from '@src/generic/hooks';
import { useSequenceNavigationMetadata } from '@src/courseware/course/sequence/sequence-navigation/hooks';
import { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import useIFrameBehavior, { stateKeys } from './useIFrameBehavior'; import useIFrameBehavior, { iframeBehaviorState } from './useIFrameBehavior';
const mockNavigate = jest.fn();
jest.mock('@edx/frontend-platform', () => ({ jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(), getConfig: jest.fn(),
@@ -22,21 +24,14 @@ jest.mock('@edx/frontend-platform/analytics');
jest.mock('react', () => ({ jest.mock('react', () => ({
...jest.requireActual('react'), ...jest.requireActual('react'),
useEffect: jest.fn(),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })), useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
})); }));
jest.mock('react-redux', () => ({ jest.mock('react-redux', () => ({
useDispatch: jest.fn(), useDispatch: jest.fn(),
useSelector: jest.fn(),
})); }));
jest.mock('lodash', () => ({
...jest.requireActual('lodash'),
throttle: jest.fn((fn) => fn),
}));
jest.mock('./useLoadBearingHook', () => jest.fn());
jest.mock('@edx/frontend-platform/logging', () => ({ jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(), logError: jest.fn(),
})); }));
@@ -50,8 +45,16 @@ jest.mock('@src/course-home/data/thunks', () => ({
jest.mock('@src/generic/hooks', () => ({ jest.mock('@src/generic/hooks', () => ({
useEventListener: jest.fn(), useEventListener: jest.fn(),
})); }));
jest.mock('@src/generic/model-store', () => ({
useModel: () => ({ unitIds: ['unit1', 'unit2'], entranceExamData: { entranceExamPassed: null } }),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const state = mockUseKeyedState(stateKeys); jest.mock('@src/courseware/course/sequence/sequence-navigation/hooks');
useSequenceNavigationMetadata.mockReturnValue({ isLastUnit: false, nextLink: '/next-unit-link' });
const props = { const props = {
elementId: 'test-element-id', elementId: 'test-element-id',
@@ -90,148 +93,147 @@ const stateVals = {
windowTopOffset: 32, windowTopOffset: 32,
}; };
const setIframeHeight = jest.fn();
const setHasLoaded = jest.fn();
const setShowError = jest.fn();
const setWindowTopOffset = jest.fn();
const mockState = (state) => {
const {
iframeHeight, hasLoaded, showError, windowTopOffset,
} = state;
if ('iframeHeight' in state) { jest.spyOn(iframeBehaviorState, 'iframeHeight').mockImplementation(() => [iframeHeight, setIframeHeight]); }
if ('hasLoaded' in state) { jest.spyOn(iframeBehaviorState, 'hasLoaded').mockImplementation(() => [hasLoaded, setHasLoaded]); }
if ('showError' in state) { jest.spyOn(iframeBehaviorState, 'showError').mockImplementation(() => [showError, setShowError]); }
if ('windowTopOffset' in state) { jest.spyOn(iframeBehaviorState, 'windowTopOffset').mockImplementation(() => [windowTopOffset, setWindowTopOffset]); }
};
describe('useIFrameBehavior hook', () => { describe('useIFrameBehavior hook', () => {
let hook;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
state.mock();
global.document.getElementById = mockGetElementById; global.document.getElementById = mockGetElementById;
global.window.addEventListener = jest.fn(); global.window.addEventListener = jest.fn();
global.window.removeEventListener = jest.fn(); global.window.removeEventListener = jest.fn();
global.window.innerHeight = 800; global.window.innerHeight = 800;
}); });
afterEach(() => {
state.resetVals();
});
describe('behavior', () => { describe('behavior', () => {
it('initializes iframe height to 0 and error/loaded values to false', () => { it('initializes iframe height to 0 and error/loaded values to false', () => {
hook = useIFrameBehavior(props); mockState(defaultStateVals);
state.expectInitializedWith(stateKeys.iframeHeight, 0); const { result } = renderHook(() => useIFrameBehavior(props));
state.expectInitializedWith(stateKeys.hasLoaded, false);
state.expectInitializedWith(stateKeys.showError, false); expect(result.current.iframeHeight).toBe(0);
state.expectInitializedWith(stateKeys.windowTopOffset, null); expect(result.current.showError).toBe(false);
expect(result.current.hasLoaded).toBe(false);
}); });
describe('effects - on frame change', () => { describe('effects - on frame change', () => {
let oldGetElement; let oldGetElement;
beforeEach(() => { beforeEach(() => {
global.window ??= Object.create(window); global.window ??= Object.create(window);
Object.defineProperty(window, 'location', { value: {}, writable: true }); Object.defineProperty(window, 'location', { value: {}, writable: true });
state.mockVals(stateVals);
oldGetElement = document.getElementById; oldGetElement = document.getElementById;
document.getElementById = mockGetElementById; document.getElementById = mockGetElementById;
mockState(defaultStateVals);
}); });
afterEach(() => { afterEach(() => {
state.resetVals(); jest.clearAllMocks();
document.getElementById = oldGetElement; document.getElementById = oldGetElement;
}); });
it('does not post url hash if the window does not have one', () => { it('does not post url hash if the window does not have one', () => {
hook = useIFrameBehavior(props); window.location.hash = '';
const cb = getEffects([ renderHook(() => useIFrameBehavior(props));
props.id,
props.onLoaded,
testIFrameHeight,
true,
], React)[0];
cb();
expect(postMessage).not.toHaveBeenCalled(); expect(postMessage).not.toHaveBeenCalled();
}); });
it('posts url hash if the window has one', () => { it('posts url hash if the window has one', () => {
window.location.hash = testHash; window.location.hash = testHash;
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
const cb = getEffects([
props.id,
props.onLoaded,
testIFrameHeight,
true,
], React)[0];
cb();
expect(postMessage).toHaveBeenCalledWith({ hashName: testHash }, config.LMS_BASE_URL); expect(postMessage).toHaveBeenCalledWith({ hashName: testHash }, config.LMS_BASE_URL);
}); });
}); });
describe('event listener', () => { describe('event listener', () => {
it('calls eventListener with prepared callback', () => { it('calls eventListener with prepared callback', () => {
state.mockVals(stateVals); mockState(stateVals);
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
const [call] = useEventListener.mock.calls; const [call] = useEventListener.mock.calls;
expect(call[0]).toEqual('message'); expect(call[0]).toEqual('message');
expect(call[1].prereqs).toEqual([ expect(call[1].prereqs).toEqual([
props.id, props.id,
props.onLoaded, props.onLoaded,
state.values.hasLoaded, stateVals.hasLoaded,
state.setState.hasLoaded, setHasLoaded,
state.values.iframeHeight, stateVals.iframeHeight,
state.setState.iframeHeight, setIframeHeight,
state.values.windowTopOffset, stateVals.windowTopOffset,
state.setState.windowTopOffset, setWindowTopOffset,
]); ]);
}); });
describe('resize message', () => { describe('resize message', () => {
const resizeMessage = (height = 23) => ({ const customHeight = 25;
const defaultHeight = 23;
const resizeMessage = (height = defaultHeight) => ({
data: { type: messageTypes.resize, payload: { height } }, data: { type: messageTypes.resize, payload: { height } },
}); });
const videoFullScreenMessage = (open = false) => ({ const videoFullScreenMessage = (open = false) => ({
data: { type: messageTypes.videoFullScreen, payload: { open } }, data: { type: messageTypes.videoFullScreen, payload: { open } },
}); });
const testSetIFrameHeight = (height = 23) => { const testSetIFrameHeight = (height = defaultHeight) => {
const { cb } = useEventListener.mock.calls[0][1]; const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage(height)); cb(resizeMessage(height));
expect(state.setState.iframeHeight).toHaveBeenCalledWith(height); expect(setIframeHeight).toHaveBeenCalledWith(height);
};
const testOnlySetsHeight = () => {
it('sets iframe height with payload height', () => {
testSetIFrameHeight();
});
it('does not set hasLoaded', () => {
expect(state.setState.hasLoaded).not.toHaveBeenCalled();
});
}; };
describe('hasLoaded', () => { describe('hasLoaded', () => {
beforeEach(() => { it('sets iframe height with payload height', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true }); mockState({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage(customHeight));
expect(setIframeHeight).toHaveBeenCalledWith(0);
expect(setIframeHeight).toHaveBeenCalledWith(customHeight);
expect(setIframeHeight).not.toHaveBeenCalledWith(defaultHeight);
}); });
testOnlySetsHeight();
});
describe('iframeHeight is not 0', () => {
beforeEach(() => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
});
testOnlySetsHeight();
}); });
describe('payload height is 0', () => { describe('payload height is 0', () => {
beforeEach(() => { hook = useIFrameBehavior(props); }); it('sets iframe height with payload height', () => {
testOnlySetsHeight(0); mockState(defaultStateVals);
renderHook(() => useIFrameBehavior(props));
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage(0));
expect(setIframeHeight).toHaveBeenCalledWith(0);
expect(setIframeHeight).not.toHaveBeenCalledWith(customHeight);
expect(setIframeHeight).not.toHaveBeenCalledWith(defaultHeight);
});
}); });
describe('payload is present but uninitialized', () => { describe('payload is present but uninitialized', () => {
beforeEach(() => {
mockState(defaultStateVals);
});
it('sets iframe height with payload height', () => { it('sets iframe height with payload height', () => {
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
testSetIFrameHeight(); testSetIFrameHeight();
}); });
it('sets hasLoaded and calls onLoaded', () => { it('sets hasLoaded and calls onLoaded', () => {
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
const { cb } = useEventListener.mock.calls[0][1]; const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage()); cb(resizeMessage());
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true); expect(setHasLoaded).toHaveBeenCalledWith(true);
expect(props.onLoaded).toHaveBeenCalled(); expect(props.onLoaded).toHaveBeenCalled();
}); });
test('onLoaded is optional', () => { test('onLoaded is optional', () => {
hook = useIFrameBehavior({ ...props, onLoaded: undefined }); renderHook(() => useIFrameBehavior({ ...props, onLoaded: undefined }));
const { cb } = useEventListener.mock.calls[0][1]; const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage()); cb(resizeMessage());
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true); expect(setHasLoaded).toHaveBeenCalledWith(true);
}); });
}); });
it('scrolls to current window vertical offset if one is set', () => { it('scrolls to current window vertical offset if one is set', () => {
const windowTopOffset = 32; const windowTopOffset = 32;
state.mockVals({ ...defaultStateVals, windowTopOffset }); mockState({ ...defaultStateVals, windowTopOffset });
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
const { cb } = useEventListener.mock.calls[0][1]; const { cb } = useEventListener.mock.calls[0][1];
cb(videoFullScreenMessage()); cb(videoFullScreenMessage());
expect(window.scrollTo).toHaveBeenCalledWith(0, windowTopOffset); expect(window.scrollTo).toHaveBeenCalledWith(0, windowTopOffset);
}); });
it('does not scroll if towverticalp offset is not set', () => { it('does not scroll if towverticalp offset is not set', () => {
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
const { cb } = useEventListener.mock.calls[0][1]; const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage()); cb(resizeMessage());
expect(window.scrollTo).not.toHaveBeenCalled(); expect(window.scrollTo).not.toHaveBeenCalled();
@@ -245,16 +247,16 @@ describe('useIFrameBehavior hook', () => {
}); });
beforeEach(() => { beforeEach(() => {
window.scrollY = scrollY; window.scrollY = scrollY;
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
[[, { cb }]] = useEventListener.mock.calls; [[, { cb }]] = useEventListener.mock.calls;
}); });
it('sets window top offset based on window.scrollY if opening the video', () => { it('sets window top offset based on window.scrollY if opening the video', () => {
cb(fullScreenMessage(true)); cb(fullScreenMessage(true));
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(scrollY); expect(setWindowTopOffset).toHaveBeenCalledWith(scrollY);
}); });
it('sets window top offset to null if closing the video', () => { it('sets window top offset to null if closing the video', () => {
cb(fullScreenMessage(false)); cb(fullScreenMessage(false));
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(null); expect(setWindowTopOffset).toHaveBeenCalledWith(null);
}); });
}); });
describe('offset message', () => { describe('offset message', () => {
@@ -266,7 +268,7 @@ describe('useIFrameBehavior hook', () => {
document.getElementById = mockGetEl; document.getElementById = mockGetEl;
const oldScrollTo = window.scrollTo; const oldScrollTo = window.scrollTo;
window.scrollTo = jest.fn(); window.scrollTo = jest.fn();
hook = useIFrameBehavior(props); renderHook(() => useIFrameBehavior(props));
const { cb } = useEventListener.mock.calls[0][1]; const { cb } = useEventListener.mock.calls[0][1];
const offset = 99; const offset = 99;
cb({ data: { offset } }); cb({ data: { offset } });
@@ -278,18 +280,85 @@ describe('useIFrameBehavior hook', () => {
}); });
}); });
describe('visibility tracking', () => { describe('visibility tracking', () => {
it('sets up visibility tracking after iframe has loaded', () => { it('sets up visibility tracking after iframe loads', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true }); mockState({ ...defaultStateVals, hasLoaded: true });
useIFrameBehavior(props);
const effects = getEffects([true, props.elementId], React); renderHook(() => useIFrameBehavior(props));
expect(effects.length).toEqual(2);
effects[0](); // Execute the visibility tracking effect.
expect(global.window.addEventListener).toHaveBeenCalledTimes(2); expect(global.window.addEventListener).toHaveBeenCalledTimes(2);
expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function)); expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
// Initial visibility update. // Initial visibility update is handled by the `handleIFrameLoad` method.
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'unit.visibilityStatus' }),
config.LMS_BASE_URL,
);
});
it('does not set up visibility tracking before iframe has loaded', () => {
window.location.hash = ''; // Avoid posting hash message.
mockState({ ...defaultStateVals, hasLoaded: false });
renderHook(() => useIFrameBehavior(props));
expect(global.window.addEventListener).not.toHaveBeenCalled();
expect(postMessage).not.toHaveBeenCalled();
});
it('cleans up event listeners on unmount', () => {
mockState({ ...defaultStateVals, hasLoaded: true });
const { unmount } = renderHook(() => useIFrameBehavior(props));
unmount(); // 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', () => {
it('sets and logs error if has not loaded', () => {
mockState(defaultStateVals);
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.handleIFrameLoad();
expect(setShowError).toHaveBeenCalledWith(true);
expect(logError).toHaveBeenCalled();
});
it('sends track event if has not loaded', () => {
mockState(defaultStateVals);
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.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', () => {
mockState({ ...defaultStateVals, hasLoaded: true });
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.handleIFrameLoad();
expect(setShowError).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
});
it('does not send track event if loaded', () => {
mockState({ ...defaultStateVals, hasLoaded: true });
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.handleIFrameLoad();
expect(sendTrackEvent).not.toHaveBeenCalled();
});
it('registers an event handler to process fetchCourse events.', () => {
mockState(defaultStateVals);
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.handleIFrameLoad();
const eventName = 'test-event-name';
const event = { data: { event_name: eventName } };
window.onmessage(event);
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
});
it('updates initial iframe visibility on load', () => {
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.handleIFrameLoad();
expect(postMessage).toHaveBeenCalledWith( expect(postMessage).toHaveBeenCalledWith(
{ {
type: 'unit.visibilityStatus', type: 'unit.visibilityStatus',
@@ -301,76 +370,37 @@ describe('useIFrameBehavior hook', () => {
config.LMS_BASE_URL, 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', () => {
it('sets and logs error if has not loaded', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
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);
hook.handleIFrameLoad();
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();
const eventName = 'test-event-name';
const event = { data: { event_name: eventName } };
window.onmessage(event);
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
});
}); });
it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => { it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => {
state.mockVals(stateVals); mockState(stateVals);
hook = useIFrameBehavior(props); const { result } = renderHook(() => useIFrameBehavior(props));
expect(hook.iframeHeight).toEqual(stateVals.iframeHeight); expect(result.current.iframeHeight).toBe(stateVals.iframeHeight);
expect(hook.showError).toEqual(stateVals.showError); expect(result.current.showError).toBe(stateVals.showError);
expect(hook.hasLoaded).toEqual(stateVals.hasLoaded); expect(result.current.hasLoaded).toBe(stateVals.hasLoaded);
});
});
describe('navigate link for the next unit on auto advance', () => {
it('test for link when it is not last unit', () => {
mockState(defaultStateVals);
renderHook(() => useIFrameBehavior(props));
const { cb } = useEventListener.mock.calls[0][1];
const autoAdvanceMessage = () => ({
data: { type: messageTypes.autoAdvance },
});
cb(autoAdvanceMessage());
expect(mockNavigate).toHaveBeenCalledWith('/next-unit-link');
});
it('test for link when it is last unit', () => {
mockState(defaultStateVals);
useSequenceNavigationMetadata.mockReset();
useSequenceNavigationMetadata.mockReturnValue({ isLastUnit: true, nextLink: '/next-unit-link' });
renderHook(() => useIFrameBehavior(props));
const { cb } = useEventListener.mock.calls[0][1];
const autoAdvanceMessage = () => ({
data: { type: messageTypes.autoAdvance },
});
cb(autoAdvanceMessage());
expect(mockNavigate).not.toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -1,25 +1,28 @@
import React, { useState } from 'react';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import React from 'react'; import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
import { fetchCourse } from '@src/courseware/data'; import { fetchCourse } from '@src/courseware/data';
import { processEvent } from '@src/course-home/data/thunks'; import { processEvent } from '@src/course-home/data/thunks';
import { useEventListener } from '@src/generic/hooks'; import { useEventListener } from '@src/generic/hooks';
import { getSequenceId } from '@src/courseware/data/selectors';
import { useModel } from '@src/generic/model-store';
import { useSequenceNavigationMetadata } from '@src/courseware/course/sequence/sequence-navigation/hooks';
import { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import useLoadBearingHook from './useLoadBearingHook'; import useLoadBearingHook from './useLoadBearingHook';
export const stateKeys = StrictDict({ export const iframeBehaviorState = {
iframeHeight: 'iframeHeight', iframeHeight: (val) => useState<number>(val), // eslint-disable-line
hasLoaded: 'hasLoaded', hasLoaded: (val) => useState<boolean>(val), // eslint-disable-line
showError: 'showError', showError: (val) => useState<boolean>(val), // eslint-disable-line
windowTopOffset: 'windowTopOffset', windowTopOffset: (val) => useState<number | null>(val), // eslint-disable-line
}); } as const;
const useIFrameBehavior = ({ const useIFrameBehavior = ({
elementId, elementId,
@@ -31,23 +34,29 @@ const useIFrameBehavior = ({
useLoadBearingHook(id); useLoadBearingHook(id);
const dispatch = useDispatch(); const dispatch = useDispatch();
const activeSequenceId = useSelector(getSequenceId);
const navigate = useNavigate();
const activeSequence = useModel('sequences', activeSequenceId);
const activeUnitId = activeSequence.unitIds.length > 0
? activeSequence.unitIds[activeSequence.activeUnitIndex] : null;
const { isLastUnit, nextLink } = useSequenceNavigationMetadata(activeSequenceId, activeUnitId);
const [iframeHeight, setIframeHeight] = useKeyedState(stateKeys.iframeHeight, 0); const [iframeHeight, setIframeHeight] = iframeBehaviorState.iframeHeight(0);
const [hasLoaded, setHasLoaded] = useKeyedState(stateKeys.hasLoaded, false); const [hasLoaded, setHasLoaded] = iframeBehaviorState.hasLoaded(false);
const [showError, setShowError] = useKeyedState(stateKeys.showError, false); const [showError, setShowError] = iframeBehaviorState.showError(false);
const [windowTopOffset, setWindowTopOffset] = useKeyedState(stateKeys.windowTopOffset, null); const [windowTopOffset, setWindowTopOffset] = iframeBehaviorState.windowTopOffset(null);
React.useEffect(() => { React.useEffect(() => {
const frame = document.getElementById(elementId); const frame = document.getElementById(elementId) as HTMLIFrameElement | null;
const { hash } = window.location; const { hash } = window.location;
if (hash) { if (hash) {
// The url hash will be sent to LMS-served iframe in order to find the location of the // The url hash will be sent to LMS-served iframe in order to find the location of the
// hash within the iframe. // hash within the iframe.
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`); frame?.contentWindow?.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
} }
}, [id, onLoaded, iframeHeight, hasLoaded]); }, [id, onLoaded, iframeHeight, hasLoaded]);
const receiveMessage = React.useCallback(({ data }) => { const receiveMessage = React.useCallback(({ data }: MessageEvent) => {
const { type, payload } = data; const { type, payload } = data;
if (type === messageTypes.resize) { if (type === messageTypes.resize) {
setIframeHeight(payload.height); setIframeHeight(payload.height);
@@ -71,7 +80,13 @@ const useIFrameBehavior = ({
} else if (data.offset) { } else if (data.offset) {
// We listen for this message from LMS to know when the page needs to // We listen for this message from LMS to know when the page needs to
// be scrolled to another location on the page. // be scrolled to another location on the page.
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop); window.scrollTo(0, data.offset + document.getElementById('unit-iframe')!.offsetTop);
} else if (type === messageTypes.autoAdvance) {
// We are listening to autoAdvance message to move to next sequence automatically.
// In case it is the last unit we need not do anything.
if (!isLastUnit && nextLink) {
navigate(nextLink);
}
} }
}, [ }, [
id, id,
@@ -87,37 +102,36 @@ const useIFrameBehavior = ({
useEventListener('message', receiveMessage); useEventListener('message', receiveMessage);
// Send visibility status to the iframe. It's used to mark XBlocks as viewed. // Send visibility status to the iframe. It's used to mark XBlocks as viewed.
const updateIframeVisibility = () => {
const iframeElement = document.getElementById(elementId) as HTMLIFrameElement | null;
const rect = iframeElement?.getBoundingClientRect();
const visibleInfo = {
type: 'unit.visibilityStatus',
data: {
topPosition: rect?.top,
viewportHeight: window.innerHeight,
},
};
iframeElement?.contentWindow?.postMessage(
visibleInfo,
`${getConfig().LMS_BASE_URL}`,
);
};
// Set up visibility tracking event listeners.
React.useEffect(() => { React.useEffect(() => {
if (!hasLoaded) { if (!hasLoaded) {
return undefined; return undefined;
} }
const iframeElement = document.getElementById(elementId); const iframeElement = document.getElementById(elementId) as HTMLIFrameElement | null;
if (!iframeElement || !iframeElement.contentWindow) { if (!iframeElement || !iframeElement.contentWindow) {
return undefined; 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. // Throttle the update function to prevent it from sending too many messages to the iframe.
const throttledUpdateVisibility = throttle(updateIframeVisibility, 100); 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. // Add event listeners to update the visibility of the iframe when the window is scrolled or resized.
window.addEventListener('scroll', throttledUpdateVisibility); window.addEventListener('scroll', throttledUpdateVisibility);
window.addEventListener('resize', throttledUpdateVisibility); window.addEventListener('resize', throttledUpdateVisibility);
@@ -152,6 +166,9 @@ const useIFrameBehavior = ({
dispatch(processEvent(e.data, fetchCourse)); dispatch(processEvent(e.data, fetchCourse));
} }
}; };
// Update the visibility of the iframe in case the element is already visible.
updateIframeVisibility();
}; };
React.useEffect(() => { React.useEffect(() => {

View File

@@ -1,19 +1,11 @@
import React from 'react'; import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useEventListener } from '@src/generic/hooks'; import { useEventListener } from '@src/generic/hooks';
export const stateKeys = StrictDict({
isOpen: 'isOpen',
options: 'options',
});
export const DEFAULT_HEIGHT = '100%'; export const DEFAULT_HEIGHT = '100%';
const useModalIFrameData = () => { const useModalIFrameData = () => {
const [isOpen, setIsOpen] = useKeyedState(stateKeys.isOpen, false); const [isOpen, setIsOpen] = React.useState(false);
const [options, setOptions] = useKeyedState(stateKeys.options, { height: DEFAULT_HEIGHT }); const [options, setOptions] = React.useState({ height: DEFAULT_HEIGHT });
const handleModalClose = () => { const handleModalClose = () => {
const rootFrame = document.querySelector('iframe'); const rootFrame = document.querySelector('iframe');

View File

@@ -1,74 +1,85 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils'; import React from 'react';
import { renderHook } from '@testing-library/react';
import { useEventListener } from '@src/generic/hooks'; import { useEventListener } from '@src/generic/hooks';
import { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import useModalIFrameData, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData'; import useModalIFrameData, { DEFAULT_HEIGHT } from './useModalIFrameData';
jest.mock('react', () => ({ jest.mock('react', () => ({
...jest.requireActual('react'), ...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })), useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
useState: jest.fn((initialValue) => [initialValue, jest.fn()]),
})); }));
jest.mock('@src/generic/hooks', () => ({ jest.mock('@src/generic/hooks', () => ({
useEventListener: jest.fn(), useEventListener: jest.fn(),
})); }));
const state = mockUseKeyedState(stateKeys); const setIsOpen = jest.fn();
const setOptions = jest.fn();
const defaultState = {
isOpen: false,
options: { height: DEFAULT_HEIGHT },
};
const mockUseStateWithValues = (values) => {
jest.spyOn(React, 'useState')
.mockReturnValueOnce([values.isOpen, setIsOpen])
.mockReturnValueOnce([values.options, setOptions]);
};
describe('useModalIFrameData', () => { describe('useModalIFrameData', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
state.mock();
}); });
const testHandleModalClose = ({ trigger }) => { const testHandleModalClose = ({ trigger }) => {
const postMessage = jest.fn(); const postMessage = jest.fn();
document.querySelector = jest.fn().mockReturnValue({ contentWindow: { postMessage } }); document.querySelector = jest.fn().mockReturnValue({ contentWindow: { postMessage } });
trigger(); trigger();
state.expectSetStateCalledWith(stateKeys.isOpen, false); expect(React.useState).toHaveBeenNthCalledWith(1, false);
expect(postMessage).toHaveBeenCalledWith({ type: 'plugin.modal-close' }, '*'); expect(postMessage).toHaveBeenCalledWith({ type: 'plugin.modal-close' }, '*');
}; };
describe('behavior', () => { describe('behavior', () => {
it('initializes isOpen to false', () => { it('should initialize with modal closed and default height', () => {
useModalIFrameData(); const { result } = renderHook(() => useModalIFrameData());
state.expectInitializedWith(stateKeys.isOpen, false);
}); expect(result.current.modalOptions).toEqual({
it('initializes options with default height', () => { isOpen: false,
useModalIFrameData(); height: DEFAULT_HEIGHT,
state.expectInitializedWith(stateKeys.options, { height: DEFAULT_HEIGHT }); });
}); });
describe('eventListener', () => { describe('eventListener', () => {
const oldOptions = { some: 'old', options: 'yeah' }; const oldOptions = { some: 'old', options: 'yeah' };
const prepareListener = () => { const prepareListener = () => {
useModalIFrameData();
expect(useEventListener).toHaveBeenCalled(); expect(useEventListener).toHaveBeenCalled();
const call = useEventListener.mock.calls[0][1]; const call = useEventListener.mock.calls[0][1];
expect(call.prereqs).toEqual([]); expect(call.prereqs).toEqual([]);
return call.cb; return call.cb;
}; };
it('consumes modal events and opens sets modal options with open: true', () => { it('consumes modal events and opens sets modal options with open: true', () => {
state.mockVals({ mockUseStateWithValues({
[stateKeys.isOpen]: false, isOpen: false,
[stateKeys.options]: oldOptions, options: oldOptions,
}); });
renderHook(() => useModalIFrameData());
const receiveMessage = prepareListener(); const receiveMessage = prepareListener();
const payload = { test: 'values' }; const payload = { test: 'values' };
receiveMessage({ data: { type: messageTypes.modal, payload } }); receiveMessage({ data: { type: messageTypes.modal, payload } });
expect(state.setState.isOpen).toHaveBeenCalledWith(true); expect(setIsOpen).toHaveBeenCalledWith(true);
expect(state.setState.options).toHaveBeenCalled(); expect(setOptions).toHaveBeenCalled();
const [[setOptionsCb]] = state.setState.options.mock.calls; const [[setOptionsCb]] = setOptions.mock.calls;
expect(setOptionsCb(oldOptions)).toEqual({ ...oldOptions, ...payload }); expect(setOptionsCb(oldOptions)).toEqual({ ...oldOptions, ...payload });
}); });
it('ignores events with no type', () => { it('ignores events with no type', () => {
state.mockVals({ const { result } = renderHook(() => useModalIFrameData());
[stateKeys.isOpen]: false, const initialState = result.current.modalOptions;
[stateKeys.options]: oldOptions,
});
const receiveMessage = prepareListener(); const receiveMessage = prepareListener();
const payload = { test: 'values' }; const payload = { test: 'values' };
receiveMessage({ data: { payload } }); receiveMessage({ data: { payload } });
expect(state.setState.isOpen).not.toHaveBeenCalled(); expect(result.current.modalOptions).toEqual(initialState);
expect(state.setState.options).not.toHaveBeenCalled();
}); });
it('calls handleModalClose behavior when receiving a "plugin.modal-close" event', () => { it('calls handleModalClose behavior when receiving a "plugin.modal-close" event', () => {
renderHook(() => useModalIFrameData());
const receiveMessage = prepareListener(); const receiveMessage = prepareListener();
testHandleModalClose({ testHandleModalClose({
trigger: () => { trigger: () => {
@@ -80,13 +91,14 @@ describe('useModalIFrameData', () => {
}); });
describe('output', () => { describe('output', () => {
test('returns handleModalClose callback', () => { test('returns handleModalClose callback', () => {
mockUseStateWithValues(defaultState);
testHandleModalClose({ trigger: useModalIFrameData().handleModalClose }); testHandleModalClose({ trigger: useModalIFrameData().handleModalClose });
}); });
it('forwards modalOptions from state values', () => { it('forwards modalOptions from state values', () => {
const modalOptions = { test: 'options' }; const modalOptions = { test: 'options' };
state.mockVals({ mockUseStateWithValues({
[stateKeys.options]: modalOptions, isOpen: true,
[stateKeys.isOpen]: true, options: modalOptions,
}); });
expect(useModalIFrameData().modalOptions).toEqual({ expect(useModalIFrameData().modalOptions).toEqual({
...modalOptions, ...modalOptions,

View File

@@ -1,19 +1,13 @@
import React from 'react'; import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useModel } from '@src/generic/model-store'; import { useModel } from '@src/generic/model-store';
import { modelKeys } from '../constants'; import { modelKeys } from '../constants';
export const stateKeys = StrictDict({
shouldDisplay: 'shouldDisplay',
});
/** /**
* @return {bool} should the honor code be displayed? * @return {bool} should the honor code be displayed?
*/ */
const useShouldDisplayHonorCode = ({ id, courseId }) => { const useShouldDisplayHonorCode = ({ id, courseId }) => {
const [shouldDisplay, setShouldDisplay] = useKeyedState(stateKeys.shouldDisplay, false); const [shouldDisplay, setShouldDisplay] = React.useState(false);
const { graded } = useModel(modelKeys.units, id); const { graded } = useModel(modelKeys.units, id);
const { userNeedsIntegritySignature } = useModel(modelKeys.coursewareMeta, courseId); const { userNeedsIntegritySignature } = useModel(modelKeys.coursewareMeta, courseId);

View File

@@ -1,22 +1,12 @@
import React from 'react'; import { renderHook } from '@testing-library/react';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useModel } from '@src/generic/model-store'; import { useModel } from '@src/generic/model-store';
import useShouldDisplayHonorCode from './useShouldDisplayHonorCode';
import { modelKeys } from '../constants'; import { modelKeys } from '../constants';
import useShouldDisplayHonorCode, { stateKeys } from './useShouldDisplayHonorCode';
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('@src/generic/model-store', () => ({ jest.mock('@src/generic/model-store', () => ({
useModel: jest.fn(), useModel: jest.fn(),
})); }));
const state = mockUseKeyedState(stateKeys);
const props = { const props = {
id: 'test-id', id: 'test-id',
courseId: 'test-course-id', courseId: 'test-course-id',
@@ -28,52 +18,29 @@ const mockModels = (graded, userNeedsIntegritySignature) => {
)); ));
}; };
describe('useShouldDisplayHonorCode hook', () => { describe('useShouldDisplayHonorCode', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockModels(false, false);
state.mock();
}); });
describe('behavior', () => {
it('initializes shouldDisplay to false', () => { it('should return false when userNeedsIntegritySignature is false', () => {
useShouldDisplayHonorCode(props); mockModels(true, false);
state.expectInitializedWith(stateKeys.shouldDisplay, false);
}); const { result } = renderHook(() => useShouldDisplayHonorCode(props));
describe('effect - on userNeedsIntegritySignature', () => { expect(result.current).toBe(false);
describe('graded and needs integrity signature', () => {
it('sets shouldDisplay(true)', () => {
mockModels(true, true);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(true);
});
});
describe('not graded', () => {
it('sets should not display', () => {
mockModels(true, false);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, false], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
});
});
describe('does not need integrity signature', () => {
it('sets should not display', () => {
mockModels(false, true);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
});
});
});
}); });
describe('output', () => {
it('returns shouldDisplay value from state', () => { it('should return false when graded is false', () => {
const testValue = 'test-value'; mockModels(false, true);
state.mockVal(stateKeys.shouldDisplay, testValue);
expect(useShouldDisplayHonorCode(props)).toEqual(testValue); const { result } = renderHook(() => useShouldDisplayHonorCode(props));
}); expect(result.current).toBe(false);
});
it('should return true when both userNeedsIntegritySignature and graded are true', () => {
mockModels(true, true);
const { result } = renderHook(() => useShouldDisplayHonorCode(props));
expect(result.current).toBe(true);
}); });
}); });

View File

@@ -22,7 +22,6 @@ const Unit = ({
onLoaded, onLoaded,
id, id,
isOriginalUserStaff, isOriginalUserStaff,
isEnabledOutlineSidebar,
renderUnitNavigation, renderUnitNavigation,
}) => { }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@@ -48,7 +47,7 @@ const Unit = ({
return ( return (
<div className="unit"> <div className="unit">
<UnitTitleSlot unitId={id} {...{ unit, isEnabledOutlineSidebar, renderUnitNavigation }} /> <UnitTitleSlot unitId={id} {...{ unit, renderUnitNavigation }} />
<UnitSuspense {...{ courseId, id }} /> <UnitSuspense {...{ courseId, id }} />
<ContentIFrame <ContentIFrame
elementId="unit-iframe" elementId="unit-iframe"
@@ -70,7 +69,6 @@ Unit.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
onLoaded: PropTypes.func, onLoaded: PropTypes.func,
isOriginalUserStaff: PropTypes.bool.isRequired, isOriginalUserStaff: PropTypes.bool.isRequired,
isEnabledOutlineSidebar: PropTypes.bool.isRequired,
renderUnitNavigation: PropTypes.func.isRequired, renderUnitNavigation: PropTypes.func.isRequired,
}; };

View File

@@ -14,7 +14,6 @@ const defaultProps = {
onLoaded: jest.fn().mockName('props.onLoaded'), onLoaded: jest.fn().mockName('props.onLoaded'),
id: 'unit-id', id: 'unit-id',
isOriginalUserStaff: false, isOriginalUserStaff: false,
isEnabledOutlineSidebar: false,
renderUnitNavigation: jest.fn(enabled => enabled && 'UnitNaviagtion'), renderUnitNavigation: jest.fn(enabled => enabled && 'UnitNaviagtion'),
}; };
@@ -68,16 +67,8 @@ describe('<Unit />', () => {
expect(screen.getByText('Bookmark this page')).toBeInTheDocument(); expect(screen.getByText('Bookmark this page')).toBeInTheDocument();
}); });
it('does not render unit navigation buttons', () => { it('renders unit navigation buttons', () => {
renderComponent(defaultProps); const props = { ...defaultProps };
const nextButton = screen.queryByText('UnitNaviagtion');
expect(nextButton).toBeNull();
});
it('renders unit navigation buttons when isEnabledOutlineSidebar is true', () => {
const props = { ...defaultProps, isEnabledOutlineSidebar: true };
renderComponent(props); renderComponent(props);
const nextButton = screen.getByText('UnitNaviagtion'); const nextButton = screen.getByText('UnitNaviagtion');

View File

@@ -1,35 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { stringifyUrl } from 'query-string';
export const iframeParams = {
show_title: 0,
show_bookmark: 0,
recheck_access: 1,
};
export const getIFrameUrl = ({
id,
view,
format,
examAccess,
jumpToId,
preview,
}) => {
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
return stringifyUrl({
url: xblockUrl,
query: {
...iframeParams,
view,
preview,
...(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.
});
};
export default {
getIFrameUrl,
};

View File

@@ -1,83 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { stringifyUrl } from 'query-string';
import { getIFrameUrl, iframeParams } from './urls';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('query-string', () => ({
stringifyUrl: jest.fn((arg) => ({ stringifyUrl: arg })),
}));
const config = { LMS_BASE_URL: 'test-lms-url' };
getConfig.mockReturnValue(config);
const props = {
id: 'test-id',
view: 'test-view',
format: 'test-format',
examAccess: { blockAccess: false, accessToken: 'test-access-token' },
preview: false,
};
describe('urls module getIFrameUrl', () => {
test('format provided, exam access and token available', () => {
const url = stringifyUrl({
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
query: {
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
preview: props.preview,
},
});
expect(getIFrameUrl(props)).toEqual(url);
});
test('no format provided, exam access blocked', () => {
const url = stringifyUrl({
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
query: { ...iframeParams, view: props.view, preview: props.preview },
});
expect(getIFrameUrl({
id: props.id,
view: props.view,
preview: props.preview,
examAccess: { blockAccess: true },
})).toEqual(url);
});
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,
preview: props.preview,
exam_access: props.examAccess.accessToken,
jumpToId: 'some-xblock-id',
},
fragmentIdentifier: 'some-xblock-id',
});
expect(getIFrameUrl({
...props,
jumpToId: 'some-xblock-id',
})).toEqual(url);
});
test('preview is true and url param equals 1', () => {
const url = stringifyUrl({
url: `${config.LMS_BASE_URL}/xblock/${props.id}`,
query: {
...iframeParams,
view: props.view,
format: props.format,
preview: true,
exam_access: props.examAccess.accessToken,
},
});
expect(getIFrameUrl({
...props,
preview: true,
})).toEqual(url);
});
});

View File

@@ -0,0 +1,42 @@
import { getConfig } from '@edx/frontend-platform';
import { getIFrameUrl } from './urls';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
const config = { LMS_BASE_URL: 'https://test-lms-url' };
getConfig.mockReturnValue(config);
const props = {
id: 'test-id',
view: 'test-view',
format: 'test-format',
examAccess: { blockAccess: false, accessToken: 'test-access-token' },
preview: false,
};
describe('urls module getIFrameUrl', () => {
test('format provided, exam access and token available', () => {
expect(getIFrameUrl(props)).toEqual('https://test-lms-url/xblock/test-id?exam_access=test-access-token&format=test-format&preview=false&recheck_access=1&show_bookmark=0&show_title=0&view=test-view');
});
test('no format provided, exam access blocked', () => {
expect(getIFrameUrl({
id: props.id,
view: props.view,
preview: props.preview,
examAccess: { blockAccess: true },
})).toEqual('https://test-lms-url/xblock/test-id?preview=false&recheck_access=1&show_bookmark=0&show_title=0&view=test-view');
});
test('jumpToId and fragmentIdentifier is added to url', () => {
expect(getIFrameUrl({
...props,
jumpToId: 'some-xblock-id',
})).toEqual('https://test-lms-url/xblock/test-id?exam_access=test-access-token&format=test-format&jumpToId=some-xblock-id&preview=false&recheck_access=1&show_bookmark=0&show_title=0&view=test-view#some-xblock-id');
});
test('preview is true and url param equals 1', () => {
expect(getIFrameUrl({
...props,
preview: true,
})).toEqual('https://test-lms-url/xblock/test-id?exam_access=test-access-token&format=test-format&preview=true&recheck_access=1&show_bookmark=0&show_title=0&view=test-view');
});
});

View File

@@ -0,0 +1,49 @@
import { getConfig } from '@edx/frontend-platform';
export const iframeParams = {
show_title: 0,
show_bookmark: 0,
recheck_access: 1,
};
interface Props {
id: string;
view: string;
format?: string | null;
examAccess: { blockAccess: boolean, accessToken?: string };
jumpToId?: string;
preview: boolean;
}
export const getIFrameUrl = ({
id,
view,
format = null,
examAccess,
jumpToId,
preview,
}: Props) => {
const xblockUrl = new URL(`${getConfig().LMS_BASE_URL}/xblock/${id}`);
for (const [key, value] of Object.entries(iframeParams)) {
xblockUrl.searchParams.set(key, String(value));
}
xblockUrl.searchParams.set('view', view);
xblockUrl.searchParams.set('preview', String(preview));
if (format) {
xblockUrl.searchParams.set('format', format);
}
if (!examAccess.blockAccess) {
xblockUrl.searchParams.set('exam_access', examAccess.accessToken!);
}
// Pass jumpToId as query param as fragmentIdentifier is not passed to server.
if (jumpToId) {
xblockUrl.searchParams.set('jumpToId', jumpToId);
xblockUrl.hash = `#${jumpToId}`; // this is used by browser to scroll to correct block.
}
xblockUrl.searchParams.sort();
return xblockUrl.toString();
};
export default {
getIFrameUrl,
};

View File

@@ -9,7 +9,7 @@ import {
import { Locked } from '@openedx/paragon/icons'; import { Locked } from '@openedx/paragon/icons';
import SidebarContext from '../../sidebar/SidebarContext'; import SidebarContext from '../../sidebar/SidebarContext';
import messages from './messages'; import messages from './messages';
import certificateLocked from '../../../../generic/assets/edX_locked_certificate.png'; import certificateLocked from '../../../../generic/assets/openedx_locked_certificate.png';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
import { UpgradeButton } from '../../../../generic/upgrade-button'; import { UpgradeButton } from '../../../../generic/upgrade-button';
import { import {

View File

@@ -4,7 +4,7 @@
} }
.lock-paywall-container svg { .lock-paywall-container svg {
color: $primary-700; color: var(--pgn-color-primary-700);
} }
@media only screen and (min-width: 992px) and (max-width: 1100px) { @media only screen and (min-width: 992px) and (max-width: 1100px) {

View File

@@ -100,13 +100,13 @@ const SequenceNavigation = ({
); );
}; };
return sequenceStatus === LOADED && ( return sequenceStatus === LOADED ? (
<nav id="courseware-sequence-navigation" data-testid="courseware-sequence-navigation" className={classNames('sequence-navigation', className, { 'mr-2': shouldDisplayNotificationTriggerInSequence })}> <nav id="courseware-sequence-navigation" data-testid="courseware-sequence-navigation" className={classNames('sequence-navigation', className, { 'mr-2': shouldDisplayNotificationTriggerInSequence })}>
{renderPreviousButton()} {renderPreviousButton()}
{renderUnitButtons()} {renderUnitButtons()}
{renderNextButton()} {renderNextButton()}
</nav> </nav>
); ) : null;
}; };
SequenceNavigation.propTypes = { SequenceNavigation.propTypes = {

View File

@@ -1,49 +0,0 @@
import React from 'react';
import { PropTypes } from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { TwitterShareButton, TwitterIcon } from 'react-share';
import { stringifyUrl } from 'query-string';
import { Icon } from '@openedx/paragon';
import messages from './messages';
const ShareTwitterIcon = () => (
<TwitterIcon
round
iconFillColor="#0A3055"
bgStyle={{
fill: '#fff',
}}
/>
);
const ShareButton = ({ url }) => {
const { formatMessage } = useIntl();
const twitterUrl = stringifyUrl({
url,
query: {
utm_source: 'twitter',
utm_medium: 'social',
utm_campaign: 'social-share-exp',
},
});
return (
<TwitterShareButton
url={twitterUrl}
title={formatMessage(messages.shareQuote)}
resetButtonStyle={false}
className="px-1 ml-n1 btn-sm text-primary-500 btn btn-link"
>
<Icon src={ShareTwitterIcon} />
{formatMessage(messages.shareButton)}
</TwitterShareButton>
);
};
ShareButton.propTypes = {
url: PropTypes.string.isRequired,
};
export default ShareButton;

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