Compare commits

...

303 Commits

Author SHA1 Message Date
Jansen Kantor
7f369caf2e fix: react state never updated when tour opened 2026-02-12 10:49:07 -05:00
Deborah Kaplan
21aecb9634 Merge pull request #43 from edx/dkaplan1/AU-2654_enable-use-of-local-translations-repo-with-improved-local-configuration-cherry-pick
feat: improved local translation handling
2026-01-23 15:11:16 -05:00
Deborah Kaplan
aefa116816 chore: Apply suggestion from @Copilot
It caught a spacing issue (this file had tabs and spaces both for some reason)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-23 13:41:10 -05:00
Deborah Kaplan
31b319b141 chore: removing blank defs
shouldn't be necessary, and don't want to override.
2026-01-23 18:31:25 +00:00
Deborah Kaplan
1f952fc454 feat: improved local translation handling
makes it easier to add instance-specific, locally defined translation
strings

FIXES: AU-2654
2026-01-23 18:31:05 +00:00
Nathan Sprenkle
a0f01cb38a refactor: shift grade summary calculation to backend (#42)
* refactor: shift grade summary calculation to backend (#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.

* chore: update snapshots

---------

Co-authored-by: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com>
2026-01-07 10:51:23 -05:00
Nathan Sprenkle
f4e88ce9ea feat: fetch exams data on the progress page (openedx#1829) (#38)
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>, Michael Roytman <mroytman@2u.com>
2025-12-17 12:56:00 -05:00
sundarthapa2u
fb6ad622e2 Merge pull request #37 from edx/sundar/upgrade-cta-to-top
feat: moved upgrade notification CTA to top
2025-12-10 14:37:19 -05:00
sundarthapa2u
2365dbdd06 feat: moved upgrade notification CTA to top 2025-12-08 19:57:35 +00:00
sundarthapa2u
9f4d82fb5d Merge pull request #36 from edx/sundar/new-course-outline
Added `isPreview` key to `outline` API
2025-12-04 14:46:44 -05:00
sundarthapa2u
47d491099f feat: updated courseblocks factory to reflect is_preview field 2025-12-04 14:41:59 -05:00
sundarthapa2u
1832786a5d feat: snapshot for isPreview 2025-12-04 14:41:59 -05:00
sundarthapa2u
a61bb7c382 feat: is_preview key mapping for normalizeOutlineBlocks 2025-12-04 14:41:59 -05:00
Jeremy Ristau
7123ab7bb1 Merge pull request #31 from edx/copilot/update-unit-title-heading
feat: change Unit title from h3 to h1 for accessibility compliance
2025-12-02 20:49:22 -05:00
nsprenkle
bfefacb940 feat: give unit title unique CSS class, preserving existing size 2025-12-02 15:20:07 -05:00
copilot-swe-agent[bot]
85b0571335 feat: update unit title from h3 to h1 for a11y compliance
Co-authored-by: jristau1984 <11785886+jristau1984@users.noreply.github.com>
2025-12-02 15:19:54 -05:00
Maniraja Raman
c13f118ac2 feat: update chat component to use PluginSlot and simplify logic 2025-11-18 11:01:21 +05:30
Jansen Kantor
3f41d5a10c Merge pull request #25 from edx/jkantor/cherry-pick-slot
feat: add plugin slot for content iframe error component (#1771)
2025-10-29 13:58:58 -04:00
Jansen Kantor
1b44ee222e fix: re-add removed import (#1815) 2025-10-28 11:17:48 -04:00
Diana Villalvazo
2728d5d4e9 test: deprecate react-unit-test-utils 1/2 (#1750) 2025-10-21 13:25:32 -04:00
Jansen Kantor
6106b65714 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 23:11:06 -04:00
Michael Roytman
8ca5513af4 Merge pull request #21 from edx/michaelroytman/edx-COSMO2-726-727-verified-learner-xpert-bug
feat: update version of frontend-lib-learning-assistant to 2.23.1
2025-10-16 08:47:20 -04:00
Michael Roytman
3245198877 feat: update version of frontend-lib-learning-assistant to 2.23.1
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 16:21:39 -04:00
Maniraja Raman
74257bc1f4 feat: add feature flag for chat v2 endpoint in LearnerAppConfig 2025-09-30 16:13:54 +05:30
Raymond Zhou
7656e602b6 Fix lti height (#19) 2025-09-11 23:54:17 -04:00
Muhammad Faraz Maqsood
69a443a571 Merge pull request #17 from edx/fix/lti_modal_height_edx
fix: height for lti modal
2025-09-09 16:49:51 +05:00
Muhammad Faraz Maqsood
2bfea2823b Merge branch 'master' into fix/lti_modal_height_edx 2025-09-09 16:37:03 +05:00
Muhammad Faraz Maqsood
35a0a6456c fix: height for lti modal 2025-09-09 16:33:46 +05:00
Muhammad Faraz Maqsood
24a9a6a761 Merge pull request #15 from edx/fix/lti_modal_height
Fix: LTI modal height
2025-09-09 16:10:58 +05:00
Muhammad Faraz Maqsood
0caa243a2e fix: height for lti modal 2025-09-09 15:25:43 +05:00
Muhammad Faraz Maqsood
724039c629 fix: modal size for lti content (#12)
- dialogClassName was working with Modal from paragon, but after paragon v23 upgrade. We replaced Modal with ModalDialog with which dialogClassName doesn't work.
  - So, replace dialogClassName with simple className to apply same styles.

Co-authored-by: Muhammad Faraz  Maqsood <faraz.maqsood@A006-01130.local>
2025-09-05 13:10:45 -04:00
Isaac Lee
e82132df5f Merge pull request #11 from edx/ilee2u/update-la-plugin-2.23.0-edx
chore: update learning assistant plugin 2.23.0
2025-08-25 15:00:27 -04:00
ilee2u
3846f1eae5 chore: update learning assistant plugin 2.23.0 2025-08-22 16:04:36 -04:00
Muhammad Adeel Tajamul
11698e055f feat: updated notification preferences unsubscribe flow (#9) 2025-08-21 14:10:47 -04:00
Nathan Sprenkle
7817ac751c feat!: add design tokens support (#1737) (#10)
BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>
Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
2025-08-15 15:06:21 -04:00
Maniraja Raman
0dfbca7cd8 feat: update frontend-lib-learning-assistant version from 2.20.0 to 2.22.0 (#8) 2025-08-11 11:13:27 -04:00
Nathan Sprenkle
5e922a1643 feat: use discount info endpoint for streak discount information (openedx#1763) (#7)
Co-authored-by: Nawfal Ahmed <111358247+NawfalAhmed@users.noreply.github.com>
2025-08-07 11:27:34 -04:00
Nathan Sprenkle
60f9abbe2b chore: update to teak.1 (#5) 2025-07-09 13:55:40 -04:00
nsprenkle
118d5aac31 revert: "feat: update certificate icons"
This reverts commit 1412bfe209.
2025-07-02 16:29:00 -04:00
nsprenkle
a8e2c080dc chore: merge branch 'openedx/release/teak.1' into edx/release/teak.1 2025-07-01 10:50:01 -04:00
Nathan Sprenkle
f0f482cc32 Merge branch 'openedx:master' into master 2025-06-13 10:34:40 -04: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
Nathan Sprenkle
dbe917f692 Merge branch 'openedx:master' into master 2025-06-05 13:30:54 -04: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
Jorg Are
69f1ca5a99 Merge branch 'openedx:master' into master 2025-06-04 16:03:48 +01: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
d4de38a8e7 Merge branch 'openedx:master' into master 2025-05-22 11:27:34 +01:00
Jorg Are
b6c29df0a0 chore: add identifiers to some upgrade links/buttons (#1686) 2025-05-21 08:45:19 -04:00
Nathan Sprenkle
6736e6cd26 Merge branch 'openedx:master' into master 2025-05-19 11:46:12 -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
b6ab78c244 Merge branch 'openedx:master' into master 2025-05-12 10:46:13 -04: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
Nathan Sprenkle
662783dbd4 revert: "feat: update certificate icons" #3 2025-05-08 16:47:04 -04:00
nsprenkle
b315c0b1e6 revert: "feat: update certificate icons"
This reverts commit 1412bfe209.
2025-05-08 16:12:55 -04: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
Maxim Beder
241e188465 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-05-05 20:43:01 +05:30
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
Brian Smith
24c9437e91 feat: import FooterSlot from component package instead of slot package (#1682) 2025-04-24 12:32:45 -04:00
Brian Smith
fb6f110732 feat: standardize slot ids (#1685) 2025-04-24 07:27:23 -04:00
renovate[bot]
1656b73a31 fix(deps): update dependency @edx/frontend-component-header to v6.4.0 (#1684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 20:19:28 +00:00
edX requirements bot
81671ad328 chore: update browserslist DB (#1681)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-04-21 00:41:04 +00:00
Ivo Branco
4cc716b20c chore(deps): update dependency @openedx/frontend-build (#1677)
This will extract more messages to be translated.
2025-04-16 15:36:46 -04:00
Braden MacDonald
756fbbac83 chore: remove 'patch-package' and its unused frontend-build patch 2025-04-15 11:56:18 -07:00
KristinAoki
903fe28ff6 refactor: change to useIntl 2025-04-15 10:45:26 -07:00
Adolfo R. Brandes
14c662dc53 feat: removes Upgrade Notification as default content
As a follow-up to
https://github.com/openedx/frontend-app-learning/pull/1368, remove the
UpgradeNotification component from the sidebar's default content.
2025-04-14 16:50:55 -03:00
Adolfo R. Brandes
af432eab27 chore: remove extraneous config file 2025-04-14 15:28:43 -03:00
edX requirements bot
dde640df33 chore: update browserslist DB (#1673)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-04-14 00:41:29 +00:00
KristinAoki
b827db800d fix: lint errors 2025-04-10 11:14:16 -04:00
KristinAoki
5b7f76b43d fix: breadcrumb preview link 2025-04-10 11:14:16 -04:00
KristinAoki
cf4bea3604 fix: unit link in preview mode 2025-04-10 11:14:16 -04:00
Feanil Patel
85e6e9266d feat: Drop canShowUpgradeSock course data.
DEPR: https://github.com/openedx/edx-platform/issues/36429

This piece of data is not being used anywhere but was still being
consumed so just drop the data so that the backend can be updated to no
longer provide the data.

The backend API is being updated in https://github.com/openedx/edx-platform/pull/36436
2025-04-09 10:13:24 -04:00
Brian Smith
360af1f0e9 feat: upgrade to react 18 (#1663) 2025-04-07 14:58:51 -04:00
edX requirements bot
26f4a90976 chore: update browserslist DB (#1670)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-04-07 00:39:51 +00:00
renovate[bot]
0d45c78ace fix(deps): update dependency @edx/frontend-platform to v8.3.4 (#1623)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 14:27:08 +00:00
renovate[bot]
c18214dc41 fix(deps): update dependency @openedx/paragon to v22.17.0 (#1666)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 07:34:37 +00:00
renovate[bot]
54611c1b4d fix(deps): update dependency @openedx/frontend-build to v14.4.2 (#1665)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 03:19:56 +00:00
renovate[bot]
7ca4b71ff7 fix(deps): update dependency @edx/frontend-lib-learning-assistant to v2.21.0 (#1664)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 22:52:12 +00:00
renovate[bot]
63a7ff83cf fix(deps): update dependency @edx/frontend-component-footer to v14.4.0 (#1662)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 19:16:07 +00:00
renovate[bot]
8ecaa018da fix(deps): update dependency @edx/react-unit-test-utils to v3.1.0 (#1652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 13:56:25 +00:00
renovate[bot]
64ca156095 fix(deps): update dependency @openedx/frontend-slot-footer to v1.1.1 (#1661)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 13:56:12 +00:00
Brian Smith
c06f2c37ab chore(deps): update @openedx dependencies to versions that support React 18 (#1656) 2025-04-02 09:49:49 -04:00
Sarina Canelake
d5a092b220 Update edx.rtd.io links to docs.openedx.org (#1654)
* docs: Update edx.rtd links to their new homes

* docs: Update README to not prescribe a version of Node
2025-03-26 17:43:44 +05:30
Kristin Aoki
81b621195e fix: button hover background color (#1653)
This PR updates the hover background for the top navigation buttons that are shown when the left side navigation is enabled. The hover background is updated to match the hover background of other existing IconButton components, see DiscussionNotificationTrigger.jsx, on the page.
2025-03-25 17:03:29 +00:00
renovate[bot]
226c4cc1d7 fix(deps): update dependency @edx/frontend-lib-special-exams to v3.4.0 (#1651)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-25 01:51:16 +00:00
renovate[bot]
7f6a59b701 fix(deps): update dependency @openedx/paragon to v22.16.1 (#1650)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 23:57:56 +00:00
renovate[bot]
7ea0bd175b fix(deps): update dependency @openedx/frontend-build to v14.3.3 (#1648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 19:56:42 +00:00
Kristin Aoki
dae1d63e23 feat: update unit title plugin (#1643)
This PR updates the unit title plugin to include all the elements that are part of the unit title, which includes the unit title, bookmark button, and navigation buttons (if left sidebar navigation is enabled).
2025-03-24 11:46:02 -04:00
edX requirements bot
6ab0deb7b7 chore: update browserslist DB (#1646)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-03-24 00:39:25 +00:00
Michael Roytman
e5f04d92b9 fix: useExamAccess hook does not make call to fetchExamAccessToken after isExam changes (#1644)
This commit fixes a bug that prevents the fetchExamAccessToken hook in the useExamAccess hook from running. This occurs when the value of isExam returned by the useIsExam hook changes from false to true. Because the dependency array of the useEffect hook within the useExamAccess hook was ['id'], the useEffect hook would not rerun on changes to isExam. The fix is to add isExam to the dependency array.
2025-03-20 15:01:40 -04:00
Kristin Aoki
f39a50e7dc feat: add section outline plugin (#1632)
This PR adds a plugin slot for the section list in the outline tab. This plugin can be used to add custom content before the list or add extra content to the titles for sections and subsections. To accomplish this, some of the smaller components inside Section and SequenceLink have been extrapolated into their own components so that they can be easily imported for use in plugins.
2025-03-18 16:51:16 -04:00
Kristin Aoki
72724bcafb fix: use sentence casing (#1640)
* fix: use sentence casing

* fix: failing tests
2025-03-18 15:28:59 -04:00
Kristin Aoki
964abbe0c3 fix: right sidebar icon behavior (#1636)
When using the right new-sidebar with the left sidebar navigation, the icon for the right sidebar changed whenever the left sidebar was open. The icon change is supposed to indicate to users that the right sidebar is open. It is confusing to users when the left sidebar navigation is open and the right sidebar icon is filled instead of outlined.
2025-03-17 09:31:23 -04:00
edX requirements bot
96d20e20e6 chore: update browserslist DB (#1638)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-03-17 00:39:37 +00:00
Kristin Aoki
a56fd7d0e1 feat: change blocked icon to lock icon (#1619)
* feat: replace blocked icon with locked

* refactor: replace injectIntl with useIntl

* revert: temporary test change

* fix: failing test

* fix: wording of message description

* fix: lingering lint error

* fix: missing message variable
2025-03-12 15:43:27 -04:00
Rodrigo Martin
679caa61f3 feat: Update link styling (#1631) 2025-03-11 12:06:28 -03:00
edX requirements bot
420060967b chore: update browserslist DB (#1630)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-03-10 00:32:49 +00:00
Michael Roytman
91d3762513 feat: update version of frontend-lib-learning-assistant to 2.19.2 (#1629)
This commit installs version 2.19.2 of @edx/frontend-lib-learning-assistant.

This release commit fixes a bug where the days remaining banner appears after an audit trial learner sends their first message. In this case, the days remaining is not displayed until the call to the chat summary endpoint completes. This commit adds a loading spinner to the banner that appears while that call is in progress.

See https://github.com/edx/frontend-lib-learning-assistant/releases/tag/v2.19.2.
2025-03-07 11:01:21 -05:00
Marcos Rigoli
2e3ed087d1 fix: Updated a11y on Courseware Search results. (#1620) 2025-03-06 15:24:35 -03:00
Rodrigo Martin
d76d4db097 feat: remove align-items-center from unit title main header (#1628) 2025-03-06 12:52:12 -03:00
renovate[bot]
04b314d157 fix(deps): update dependency @openedx/paragon to v22.16.0 (#1626)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-06 05:43:15 +00:00
renovate[bot]
1db4848d1a fix(deps): update dependency @openedx/frontend-slot-footer to v1.1.0 (#1625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-06 02:18:14 +00:00
renovate[bot]
8f294781d2 fix(deps): update dependency @openedx/frontend-plugin-framework to v1.5.0 (#1624)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-05 22:06:50 +00:00
renovate[bot]
d6908abb13 chore(deps): update dependency @testing-library/user-event to v14.6.1 2025-03-05 14:00:48 -08:00
Braden MacDonald
96d3d0da7e chore: remove husky 2025-03-05 16:39:59 -03:00
renovate[bot]
14cc32fcf6 fix(deps): update dependency @openedx/frontend-build to v14.3.2 (#1616)
* fix(deps): update dependency @openedx/frontend-build to v14.3.2

* fix: 'unitId' PropType is defined but prop is never used

* fix: bump maximum allowed bundle size for now :/

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-03-05 18:04:37 +00:00
Jansen Kantor
0ac127e4c9 feat: add plugin slot for course end course recommendations (#1618) 2025-03-04 15:12:53 -05:00
Rodrigo Martin
06e5fb5a44 feat: Update previous and next unit navigation buttons design (#1617)
* feat: Update previous and next unit navigation buttons design

* feat: add unit test

* feat: move unit navigation to be inline with unit title
2025-03-04 11:05:39 -03:00
Agrendalath
2235737490 feat: close sidebar on mobile after selecting a unit 2025-03-03 13:28:59 -08:00
edX requirements bot
fca32ae872 chore: update browserslist DB (#1567)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-03-03 10:16:40 -08:00
Braden MacDonald
ae04e5b366 fix: remove husky internal files that should never have been committed 2025-02-28 10:18:31 -08:00
Marcos Rigoli
db3f1b9cb0 feat: Search a11y updated behavior. (#1614)
* feat: Updated accesibility on courseware search modal

* chore: Updated i18n to use useIntl() hook and dialog behavior updates

* feat: Added close when clicking on the modal backdrop

* chore: Swapped remove listeners with an AbortController

* fix: Fixed tests

* chore: Rolled back unintended husky script change
2025-02-28 10:12:39 -05:00
renovate[bot]
ec360bc545 fix(deps): update dependency @edx/frontend-platform to v8.2.1 (#1615)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 14:44:43 +00:00
renovate[bot]
6c5220b4d7 fix(deps): update dependency @edx/frontend-lib-special-exams to v3.3.0 (#1613)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 10:38:07 +00:00
renovate[bot]
f433118a8d fix(deps): update dependency @edx/browserslist-config to v1.5.0 (#1612)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 06:41:56 +00:00
renovate[bot]
e798331855 fix(deps): update dependency @edx/frontend-component-header to v5.8.3 (#1609)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 00:54:24 +00:00
renovate[bot]
adb5796ff6 fix(deps): update dependency sass-loader to v16.0.5 (#1610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 00:53:56 +00:00
renovate[bot]
9958638a86 fix(deps): update dependency @reduxjs/toolkit to v1.9.7 2025-02-27 16:47:57 -08:00
renovate[bot]
b8e844eba6 fix(deps): update dependency sass to v1.85.1 2025-02-27 16:43:20 -08:00
Varsha Menon
da633ffbd9 feat: upgrade learning assistant frontend to 2.19.1 optimizely bug fix (#1607) 2025-02-27 10:15:04 -05:00
Rodrigo Martin
46889c2aba feat(AU-2375): Update breadcrumbs and outline navigation bar UI (#1582)
* feat: extend CourseOutlineSidebarTriggerSlot props

* feat: Remove courseId from CourseOutlineSidebarTriggerPlugin props

* feat: update useContextId to use courseware data along with coursehome

* feat: extend useCourseOutlineSidebar values with sequenceStatus
2025-02-27 11:24:05 -03:00
Kristin Aoki
3cbbb0272b fix: update outline sidebar hooks for plugins (#1586)
* fix: update outline sidebar hooks for plugins

* docs: explain UnitLinkWrapper
2025-02-21 14:49:52 -05:00
Michael Roytman
911c7658f5 feat: update version of frontend-lib-special-exams library from 3.2.0 to 3.2.1 (#1591)
This commit updates the version of the frontend-lib-special-exams library from 3.2.0 to 3.2.1. The full changelog is available here: openedx/frontend-lib-special-exams@3.2.0...3.2.1: https://github.com/openedx/frontend-lib-special-exams/compare/v3.2.0...v3.2.1.

The changes in this new version are summarized below.
* Update exam message for submitted proctored exams with more accurate language.
2025-02-21 11:28:07 -05:00
Varsha Menon
b54d1e467e Revert "chore: upgrade frontend lib learning assistant version (#1590)" (#1592)
This reverts commit e34d18d727.
2025-02-21 10:38:28 -05:00
Varsha Menon
e34d18d727 chore: upgrade frontend lib learning assistant version (#1590) 2025-02-19 12:18:51 -05:00
Kyle McCormick
6949e5708f chore: make sure links point to MFE not legacy (#1589)
now that the legacy profile and account pages have been removed, we need to make sure that all of the links point to the MFE URLs; we were relying on the legacy applications to do redirection before.

FIXES: APER-3884
FIXES: openedx/public-engineering#71

Co-authored-by: Deborah Kaplan <deborahgu@users.noreply.github.com>
2025-02-18 08:59:15 -08:00
Stanislav
eef6b1efe2 fix: Ugly appearance of the Start/Resume Course button at the top of the course outline (#1585) 2025-02-14 18:17:21 -08:00
Kristin Aoki
9dc45e192d fix: accessibility issues on outline and unit pages (#1580)
This PR fixes the following accessibility issues:

1. Header used for screenreader only text
2. Element focus when expanding and dismissing welcome message
3. Bookmark button using wrong ARIA attributing while processing bookmark status
2025-01-31 14:18:28 -05:00
Marcos Rigoli
bd9c97c269 fix: Unify Xpert audit trial eligibility between backend and frontend (#1581)
* fix: Refactored Chat to be easier to read

* chore: Fixed comment typo
2025-01-30 16:35:39 -03:00
Alison Langston
c70fb138f0 feat: update proctoring info panel api call (#1579) 2025-01-29 15:00:13 -05:00
Kristin Aoki
8823cfaa0a feat: update next unit button plugin for left sidebar navigation usage (#1578)
* feat: move unit next button slot to plugins folder

* feat: update unit navigation at top to use next unit plugin

* fix: remove 2u plugin specific code
2025-01-28 13:01:18 -05:00
salmannawaz
7865fadec2 chore: update catalog-info file and remove openedx.yaml (#1575) 2025-01-24 09:39:36 -05:00
Marcos Rigoli
5be1620f1d fix: Updated learning assistant to v2.14.2 (#1577) 2025-01-21 17:17:38 -03:00
Kshitij Sobti
d5a6a59d07 feat: Add plugin slots for progress page components (#1496) 2025-01-16 12:56:07 -05:00
Braden MacDonald
826f1382dd * fix(deps): update @openedx/paragon to v22.13.0, fix minor TypeScript warning (#1572)
* fix(deps): update dependency @openedx/paragon to v22.13.0

* fix: update use of useWindowSize() to reflect accurate data types

* chore: allow slightly larger bundle size for new paragon :/

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-10 10:09:17 -08:00
Awais Ansari
5e5fdeba44 fix: updated notifiations preferences url (#1573) 2025-01-10 17:13:15 +05:00
dependabot[bot]
01369eb00d chore(deps): bump cross-spawn from 7.0.3 to 7.0.6
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-08 17:25:18 -08:00
renovate[bot]
4bb4bb7a88 fix(deps): update dependency @edx/browserslist-config to v1.4.0 (#1570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-08 00:40:32 +00:00
renovate[bot]
1d154f46c1 fix(deps): update dependency @edx/frontend-platform to v8.1.5 (#1568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-06 22:58:18 +00:00
renovate[bot]
420db8133f chore(deps): update dependency jest-when to v3.7.0 2025-01-06 14:53:06 -08:00
Kristin Aoki
1ffc93dc6d feat: update grade summary to show floating point grades (#1558)
This PR resolves the bug that shows assignment's weighted grades that do not sum to the correct total grade. When a learner's weighted grades round down to the nearest whole number, but the summation of the weighted grades will round to a higher percent than the pre-rounded summation. To clarify this for users, the assignment's weighted grade will now show 2 decimal points, matching the legacy display found in the grade graph. To further clarify the difference, a tooltip was added to show the learner the raw weighted grade and the rounded weighted grade.
2025-01-06 15:12:40 -05:00
Marcos Rigoli
346e15abd4 feat: Updated frontend-lib-learning-assistant to v2.14.1 (#1559) 2025-01-03 13:00:10 -03:00
renovate[bot]
4726c23bc3 fix(deps): update dependency sass-loader to v16.0.4 (#1565)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-03 02:01:20 +00:00
renovate[bot]
cbbb417894 fix(deps): update dependency @openedx/frontend-slot-footer to v1.0.7 (#1564)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 22:50:50 +00:00
renovate[bot]
c57f28ad40 fix(deps): update dependency @openedx/frontend-build to v14.2.2 (#1563)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 18:25:52 +00:00
renovate[bot]
310fb84517 fix(deps): update dependency @edx/frontend-platform to v8.1.4 (#1562)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 16:06:10 +00:00
renovate[bot]
623f6946e5 fix(deps): update dependency @edx/frontend-component-header to v5.8.2 (#1561)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 15:54:37 +00:00
renovate[bot]
cf124877e8 chore(deps): update dependency eslint-import-resolver-webpack to v0.13.10 (#1560)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 14:40:47 +00:00
renovate[bot]
0456ad9318 fix(deps): update dependency husky to v9 (#1545)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 11:34:18 -03:00
edX requirements bot
7be87b0f83 chore: update browserslist DB (#1555)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-01-02 11:33:07 -03:00
Rodrigo Martin
cbe5b28762 fix(AU-2174): Fix left sidebar throwing 404 (#1556) 2024-12-20 13:09:10 -03:00
Abdur Rahman Asad
4a80532b8d fix: update iframe feature policy
This is needed to fix Xblock video play button not working in Chrome for youtube videos due to iframe security policy.
2024-12-18 09:38:00 -08:00
Marcos Rigoli
e505f78cfb feat: Updated frontend-lib-learning-assistant to v2.13.0 (#1557) 2024-12-18 12:50:19 -03:00
dependabot[bot]
3811f5f9d5 chore(deps): bump nanoid from 3.3.7 to 3.3.8
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 11:17:29 -08:00
Varsha Menon
8a20b908c7 feat: upgrade learning assistant (#1553) 2024-12-16 10:09:34 -05:00
Kristin Aoki
8a6fa937ea feat: add progress certificate status plugin slot (#1551) 2024-12-11 13:24:33 +01:00
Alison Langston
dafdcad2b4 feat: update gating for chat component (#1550)
* feat: update gating for chat component

* fix: add gating for access expiration

* chore: upgrade learning assistant version
2024-12-09 16:21:40 -05:00
dependabot[bot]
cd56ffaf9d chore(deps): bump path-to-regexp and express
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `path-to-regexp` from 0.1.10 to 0.1.12
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v0.1.10...v0.1.12)

Updates `express` from 4.21.1 to 4.21.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.2/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.1...4.21.2)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-09 12:06:31 -08:00
edX requirements bot
c11cb85d78 chore: update browserslist DB (#1548)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-12-09 10:04:40 -08:00
Farhaan Bukhsh
b09bcbd3ae Revert PR #1519 "fix: only one in-course experience sidebar can be open..."
(and "fix: add test in CourseOutlineTray")
This reverts commit 020e7fb42 and 038b05ba
2024-12-04 11:06:35 -08:00
Braden MacDonald
4a925f9c11 refactor: convert masquerade UI widgets to Function Components + TypeScript (#1513)
* refactor: convert masquerade UI widgets to TypeScript

* test: improve test coverage

* chore: upgrade @testing-library/user-event to v14

* test: improve test coverage

* test: improve test coverage
2024-12-04 22:33:06 +05:30
Brian Smith
f5b6243c61 feat: wrap existing sidebars in frontend-plugin-framework PluginSlots (#1543) 2024-12-04 10:24:03 -05:00
dependabot[bot]
98c670afe7 chore(deps): bump http-proxy-middleware from 2.0.6 to 2.0.7
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 09:34:26 -08:00
jciasenza
038b05ba6c fix: add test in CourseOutlineTray 2024-11-30 20:57:59 +05:30
jciasenza
020e7fb42c fix: only one in-course experience sidebar can be open at a time failing 2024-11-30 20:57:59 +05:30
Demid
ead98538b9 feat: hide studio button for limited staff (#1436) 2024-11-29 00:36:05 +05:30
renovate[bot]
90ef6ace5c fix(deps): update dependency @openedx/frontend-plugin-framework to v1.4.1 (#1544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-26 22:50:10 +00:00
renovate[bot]
e0196f2a2a fix(deps): update dependency copy-webpack-plugin to v12 2024-11-26 14:41:49 -08:00
edX requirements bot
b7befcff7e chore: update browserslist DB (#1541)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-11-25 10:38:56 -08:00
Varsha Menon
642031bf87 feat: upgrade learning assistant (#1542) 2024-11-25 12:02:51 -05:00
Brian Smith
f778f27647 feat: update to header with new FPF pluginProps (#1524) 2024-11-22 15:20:58 -05:00
renovate[bot]
b3bce8713c chore(deps): update dependency @pact-foundation/pact to v13.2.0 2024-11-21 13:59:06 -08:00
renovate[bot]
dacb30c73e chore(deps): update actions/checkout action to v4 2024-11-21 09:37:10 -08:00
renovate[bot]
81a4deeec0 fix(deps): update dependency @openedx/paragon to v22.10.0 (#1536)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-21 05:14:05 +00:00
renovate[bot]
9a1b05a1a4 fix(deps): update dependency @openedx/frontend-plugin-framework to v1.4.0 (#1535)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-21 00:55:38 +00:00
renovate[bot]
b9e1fb0d2b fix(deps): update dependency @edx/frontend-lib-special-exams to v3.2.0 (#1534)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 22:04:03 +00:00
renovate[bot]
ebd0f8816c fix(deps): update dependency @edx/frontend-lib-learning-assistant to v2.5.0 (#1533)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 20:47:48 +00:00
renovate[bot]
d749429361 fix(deps): update dependency @edx/frontend-component-header to v5.8.0 (#1532)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 17:12:33 +00:00
renovate[bot]
19b8df35ae chore(deps): update dependency axios-mock-adapter to v2.1.0 2024-11-20 09:06:54 -08:00
renovate[bot]
e468d2087b fix(deps): update dependency sass-loader to v16.0.3 (#1530)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 11:23:37 +00:00
renovate[bot]
42e0ac86d7 fix(deps): update dependency @openedx/frontend-slot-footer to v1.0.6 (#1529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 08:36:02 +00:00
renovate[bot]
ea5cf37fd8 fix(deps): update dependency @edx/frontend-platform to v8.1.2 (#1528)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 03:33:02 +00:00
renovate[bot]
e4cdec7389 chore(deps): update dependency @pact-foundation/pact to v13.1.5 (#1527)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 00:14:12 +00:00
renovate[bot]
8aafc6b8bd fix(deps): update dependency redux to v4.2.1 2024-11-19 16:08:56 -08:00
dependabot[bot]
913c8e4086 chore(deps): bump codecov/codecov-action from 4 to 5
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-18 11:27:30 -08:00
Nathan Sprenkle
c221770213 chore: bump frontend-build to 14.2.0 (#1522) 2024-11-18 14:02:51 -05:00
Nathan Sprenkle
4fe40c264f feat: add configurable robots meta tag (#1520) 2024-11-18 12:47:52 -05:00
Marcos Rigoli
c20c7677a3 chore: Updated @edx/frontend-lib-learning-assistant to v2.4.1 (#1518) 2024-11-14 15:22:23 -03:00
Kristin Aoki
2ff8c3949e fix: update skip nav destination (#1516) 2024-11-12 16:18:09 -05:00
edX requirements bot
4a5c43d365 chore: update browserslist DB (#1515)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-11-12 12:32:57 -08:00
Kristin Aoki
4da37f369b feat: add page not found route (#1514)
* feat: add page not found route

* feat: add logging

* feat: add tests
2024-11-12 09:05:24 -05:00
edX requirements bot
0effb32318 chore: update browserslist DB (#1511)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-11-04 12:26:21 -08:00
Roman Edirisinghe
6813872dd3 fix: corrects typo in IFRAME_FEATURE_POLICY 2024-11-04 09:47:37 -08:00
Kristin Aoki
e337a367d1 fix: xblock error mfe unit preview (#1508)
* feat: add functionality to see unit draft preview

* fix: course redirect unit to sequnce unit redirect

* fix: not showing preview when masquerading

* feat: in preview fetch draft branch of sequence metadata
2024-11-01 13:22:24 -04:00
Bilal Qamar
65343470e1 test: Remove support for Node 18 (#1454)
* test: Remove support for Node 18

* chore: update code coverage artifact naming
2024-10-31 15:35:17 -04:00
Navin Karkera
e69114a839 feat: option to show/hide ungraded assignment in progress page (#1380)
* feat: option to show/hide ungraded assignment in progress page

test: add tests for show ungraded toggle

feat: update score label in progress page based on grading

refactor: update score label text and add tooltip

refactor: move label tooltip near header as normal text

refactor: update problem score label Graded scores

* refactor: move config check to utils
2024-10-31 15:48:24 +05:30
Alison Langston
2d63a14c2e feat: update courseware search api name (#1507) 2024-10-30 08:52:54 -04:00
Juan Carlos Iasenza (Aulasneo)
2d1f893a40 fix: untranslatable strings in Instructor Toolbar #1193 (#1505) 2024-10-29 14:34:08 -07:00
edX requirements bot
64f92deeb1 chore: update browserslist DB (#1506)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2024-10-28 11:28:11 -07:00
Kristin Aoki
d47433ee83 feat: add functionality to see unit draft preview (#1501)
* feat: add functionality to see unit draft preview

* feat: add tests for course link redirects

* fix: course redirect unit to sequnce unit redirect

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

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

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

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

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

* fix: change active selection based on user input

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

* fix: updated/resolved failing test

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

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

* refactor: removed eslint disable

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

---------

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

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

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

* fix: code refactors to resolve failing tests

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

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

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

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

* fix: fixed first time view issue

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

* feat: fix tests

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

* chore: make sidebar less intrusive for mobile

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

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

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

* chore: Updated mocked PluginSlot

* chore: Added unit test for MockedPluginSlot

* fix: Updated slot name ids

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

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

* feat: generic sidebar notification plugin (#1379)

* feat: make notification plugin api generic

* feat: include new sidebar and tests

* feat: tweak sidebar toggle

* style: fix extra space from merge

* feat: rename plugin slots

---------

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

6
.env
View File

@@ -4,6 +4,7 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
APP_ID='learning'
BASE_URL=''
CONTACT_URL=''
CREDENTIALS_BASE_URL=''
@@ -11,10 +12,12 @@ CREDIT_HELP_LINK_URL=''
CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
ENTERPRISE_LEARNER_PORTAL_URL=''
EXAMS_BASE_URL=''
FAVICON_URL=''
IGNORED_ERROR_REGEX=''
@@ -47,3 +50,6 @@ TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -4,17 +4,20 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
APP_ID='learning'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
EXAMS_BASE_URL=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
@@ -49,3 +52,6 @@ SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -4,17 +4,20 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
APP_ID='learning'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
@@ -46,3 +49,6 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:Enterprise'
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'

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

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

View File

@@ -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,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

View File

@@ -10,15 +10,27 @@ jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VER }}
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: coverage/*.*
coverage:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v4
- name: Download code coverage results
uses: actions/download-artifact@v4
with:
name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
token: ${{ secrets.CODECOV_TOKEN }}

1
.husky/_/.gitignore vendored
View File

@@ -1 +0,0 @@
*

View File

@@ -1,31 +0,0 @@
#!/bin/sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
export readonly husky_skip_init=1
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
exit $exitCode
fi

View File

@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

2
.nvmrc
View File

@@ -1 +1 @@
18
20

View File

@@ -40,9 +40,10 @@ pull_translations:
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-lib-special-exams/src/i18n/messages:frontend-lib-special-exams \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning \
$(ATLAS_EXTRA_SOURCES)
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning $(ATLAS_EXTRA_INTL_IMPORTS)
# This target is used by Travis.
@@ -55,8 +56,10 @@ validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run types
npm run test
npm run build
npm run bundlewatch
.PHONY: validate.ci
validate.ci:

View File

@@ -1,77 +1,109 @@
#####################
frontend-app-learning
#####################
|codecov| |license|
********
Purpose
********
*******
This is the Learning MFE (micro-frontend application), which renders all
learner-facing course pages (like the course outline, the progress page,
actual course content, etc).
Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
***************
Getting Started
***************
Prerequisites
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
`Tutor`_ is currently recommended as a development environment for the Learning
MFE. Most likely, it already has this MFE configured; however, you'll need to
make some changes in order to run it in development mode. You can refer
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
guide below.
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
Cloning and Setup
=================
Cloning and Startup
===================
1. Clone your new repo:
.. code-block::
.. code-block:: bash
1. Clone your new repo:
git clone https://github.com/openedx/frontend-app-learning.git
``git clone https://github.com/openedx/frontend-app-learning.git``
2. Use the version of Node specified in ``.nvmrc``.
2. Use node v18.x.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
3. Install npm dependencies:
4. Next, we need to tell Tutor that we're going to be running this repo in
development mode, and it should be excluded from the ``mfe`` container that
otherwise runs every MFE. Run this:
``cd frontend-app-learning && npm ci``
.. code-block:: bash
4. Start the dev server:
tutor mounts add /path/to/frontend-app-learning
``npm start``
5. Start Tutor in development mode. This command will start the LMS and Studio,
and other required MFEs like ``authn`` and ``account``, but will not start
the learning MFE, which we're going to run on the host instead of in a
container managed by Tutor. Run:
.. code-block:: bash
tutor dev start lms cms mfe
Startup
=======
1. Install npm dependencies:
.. code-block:: bash
cd frontend-app-learning && npm ci
2. Start the dev server:
.. code-block:: bash
npm run dev
Then you can access the app at http://local.openedx.io:2000/learning/
Troubleshooting
---------------
If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
these commands to update your devstack's domain names:
.. code-block:: bash
tutor dev stop
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
tutor dev launch -I --skip-build
tutor dev stop learning # We will run this MFE on the host
Local module development
=========================
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
file (which is git-ignored) that defines where to find your local modules, for instance::
file (which is git-ignored) that defines where to find your local modules, for instance:
.. code-block:: js
module.exports = {
/*
@@ -98,15 +130,21 @@ Deployment
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
`MFE applications <https://openedx.github.io/frontend-platform/>`_.
Plugins
=======
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
Environment Variables
======================
=====================
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
as documented in the Open edX Developer Guide under
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
`Required Environment Variables <https://openedx.github.io/frontend-platform/>`_.
The learning micro-frontend also supports the following additional variables:
@@ -127,7 +165,7 @@ SOCIAL_UTM_MILESTONE_CAMPAIGN
SUPPORT_URL_CALCULATOR_MATH
A link that explains how to use the in-course calculator. You can use the
one in the example below, if you don't want to have your own branded version.
one in the example below if you don't want to have your own branded version.
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
@@ -140,7 +178,7 @@ SUPPORT_URL_ID_VERIFICATION
SUPPORT_URL_VERIFIED_CERTIFICATE
A link that explains what a verified certificate is. You can use the
one in the example below, if you don't want to have your own branded version.
one in the example below if you don't want to have your own branded version.
Optional.
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
@@ -156,13 +194,13 @@ TWITTER_URL
A link to your Twitter account. The Twitter social-share link won't appear
unless this is set. Optional.
Example: https://twitter.com/edXOnline
Example: https://twitter.com/openedx
Getting Help
===========
============
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
If you're having trouble, we have `discussion forums`_
where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
@@ -180,17 +218,18 @@ For more information about these options, see the `Getting Help`_ page.
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
.. _discussion forums: https://discuss.openedx.org
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
to discuss your new feature idea with the maintainers before
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.

View File

@@ -12,7 +12,8 @@ metadata:
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:2u-aurora
owner: group:committers-frontend-app-learning
type: 'website'
lifecycle: 'production'

View File

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

View File

@@ -1,10 +0,0 @@
# This file describes this Open edX repo, as described in OEP-2:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
oeps: {}
owner: edx/platform-core-tnl
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

20146
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,17 @@
],
"scripts": {
"build": "fedx-scripts webpack",
"bundlewatch": "bundlewatch",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
"postinstall": "patch-package",
"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 .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
"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",
"test": "NODE_ENV=test fedx-scripts jest --coverage --passWithNoTests",
"test:watch": "fedx-scripts jest --watch --passWithNoTests",
"types": "tsc --noEmit"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -31,66 +34,68 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^13.0.4",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-lib-learning-assistant": "^2.0.0",
"@edx/frontend-lib-special-exams": "^3.0.1",
"@edx/frontend-platform": "^7.1.2",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-lib-learning-assistant": "^2.24.0",
"@edx/frontend-lib-special-exams": "^4.0.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.1.4",
"@openedx/frontend-plugin-framework": "^1.0.2",
"@openedx/paragon": "^22.3.0",
"@openedx/frontend-build": "^14.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"@reduxjs/toolkit": "1.9.7",
"buffer": "^6.0.3",
"classnames": "2.5.1",
"copy-webpack-plugin": "^12.0.0",
"joi": "^17.11.0",
"js-cookie": "3.0.5",
"lodash": "^4.17.21",
"lodash.camelcase": "4.3.0",
"postcss-loader": "^8.1.1",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
"react": "17.0.2",
"react-dom": "17.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-share": "4.4.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
"redux": "4.2.1",
"reselect": "4.1.8",
"truncate-html": "1.0.4",
"util": "0.12.5"
"sass": "^1.79.3",
"sass-loader": "^16.0.2",
"source-map-loader": "^5.0.0",
"truncate-html": "1.0.4"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/reactifex": "2.2.0",
"@openedx/frontend-build": "13.1.4",
"@pact-foundation/pact": "^11.0.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"eslint-import-resolver-webpack": "^0.13.8",
"husky": "7.0.4",
"jest": "^26.6.3",
"jest-console-group-reporter": "^1.0.1",
"@pact-foundation/pact": "^13.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "14.6.1",
"axios-mock-adapter": "2.1.0",
"bundlewatch": "^0.4.0",
"eslint-import-resolver-webpack": "^0.13.9",
"jest": "^29.7.0",
"jest-console-group-reporter": "^1.1.1",
"jest-when": "^3.6.0",
"patch-package": "^8.0.0",
"postcss-loader": "^8.1.1",
"rosie": "2.1.1",
"sass": "^1.72.0",
"sass-loader": "^14.1.1",
"source-map-loader": "^5.0.0",
"style-loader": "^3.3.4"
"rosie": "2.1.1"
},
"bundlewatch": {
"files": [
{
"path": "dist/*.js",
"maxSize": "1450kB"
}
],
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
}
}

View File

@@ -1,36 +0,0 @@
diff --git a/node_modules/@openedx/frontend-build/config/webpack.prod.config.js b/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
index 2879dd9..9efd0fc 100644
--- a/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
+++ b/node_modules/@openedx/frontend-build/config/webpack.prod.config.js
@@ -12,6 +12,7 @@ const NewRelicSourceMapPlugin = require('@edx/new-relic-source-map-webpack-plugi
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const path = require('path');
+const fs = require('fs');
const PostCssAutoprefixerPlugin = require('autoprefixer');
const PostCssRTLCSS = require('postcss-rtlcss');
const PostCssCustomMediaCSS = require('postcss-custom-media');
@@ -23,6 +24,23 @@ const HtmlWebpackNewRelicPlugin = require('../lib/plugins/html-webpack-new-relic
const commonConfig = require('./webpack.common.config');
const presets = require('../lib/presets');
+/**
+ * This condition confirms whether the configuration for the MFE has switched to a JS-based configuration
+ * as previously implemented in frontend-build and frontend-platform. If the environment variable JS_CONFIG_FILEPATH
+ * exists, then an env.config.js(x) file will be copied from the location referenced by the environment variable to the
+ * root directory. Its env variables can be accessed with getConfig().
+ *
+ * https://github.com/openedx/frontend-build/blob/master/docs/0002-js-environment-config.md
+ * https://github.com/openedx/frontend-platform/blob/master/docs/decisions/0007-javascript-file-configuration.rst
+ */
+
+const envConfigPath = process.env.JS_CONFIG_FILEPATH;
+
+if (envConfigPath) {
+ const envConfigFilename = envConfigPath.slice(envConfigPath.indexOf('env.config'));
+ fs.copyFileSync(envConfigPath, envConfigFilename);
+}
+
// Add process env vars. Currently used only for setting the PUBLIC_PATH.
dotenv.config({
path: path.resolve(process.cwd(), '.env'),

View File

@@ -9,6 +9,9 @@
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
<% } %>
<% if (htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR) { %>
<meta name="robots" content="<%= htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR %>">
<% } %>
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,228 @@
// 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>
<div
className="app-container"
>
<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>
</div>
</UserMessagesProvider>
</NoticesProvider>
</PathFixesProvider>
</AppProvider>
</React Strict Mode>
`;

View File

@@ -1,14 +1,13 @@
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FormattedMessage, FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import messages from './messages';
const AccessExpirationAlert = ({ intl, payload }) => {
const AccessExpirationAlert = ({ payload }) => {
const intl = useIntl();
const {
accessExpiration,
courseId,
@@ -119,7 +118,6 @@ const AccessExpirationAlert = ({ intl, payload }) => {
};
AccessExpirationAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
@@ -134,4 +132,4 @@ AccessExpirationAlert.propTypes = {
}).isRequired,
};
export default injectIntl(AccessExpirationAlert);
export default AccessExpirationAlert;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Hyperlink } from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
@@ -7,7 +7,8 @@ import { WarningFilled } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import genericMessages from './messages';
const ActiveEnterpriseAlert = ({ intl, payload }) => {
const ActiveEnterpriseAlert = ({ payload }) => {
const intl = useIntl();
const { text, courseId } = payload;
const changeActiveEnterprise = (
<Hyperlink
@@ -38,11 +39,10 @@ const ActiveEnterpriseAlert = ({ intl, payload }) => {
};
ActiveEnterpriseAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
text: PropTypes.string,
courseId: PropTypes.string,
}).isRequired,
};
export default injectIntl(ActiveEnterpriseAlert);
export default ActiveEnterpriseAlert;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Button } from '@openedx/paragon';
import { Info, WarningFilled } from '@openedx/paragon/icons';
@@ -11,7 +11,8 @@ import { useModel } from '../../generic/model-store';
import messages from './messages';
import useEnrollClickHandler from './clickHook';
const EnrollmentAlert = ({ intl, payload }) => {
const EnrollmentAlert = ({ payload }) => {
const intl = useIntl();
const {
canEnroll,
courseId,
@@ -58,7 +59,6 @@ const EnrollmentAlert = ({ intl, payload }) => {
};
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
canEnroll: PropTypes.bool,
courseId: PropTypes.string,
@@ -67,4 +67,4 @@ EnrollmentAlert.propTypes = {
}).isRequired,
};
export default injectIntl(EnrollmentAlert);
export default EnrollmentAlert;

View File

@@ -9,13 +9,12 @@ import {
Icon,
} from '@openedx/paragon';
import { Check, ArrowForward } from '@openedx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';
const AccountActivationAlert = ({
intl,
}) => {
const AccountActivationAlert = () => {
const intl = useIntl();
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
@@ -125,8 +124,4 @@ const AccountActivationAlert = ({
);
};
AccountActivationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AccountActivationAlert);
export default AccountActivationAlert;

View File

@@ -1,13 +1,14 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Hyperlink } from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
import genericMessages from '../../generic/messages';
const LogistrationAlert = ({ intl }) => {
const LogistrationAlert = () => {
const intl = useIntl();
const signIn = (
<Hyperlink
style={{ textDecoration: 'underline' }}
@@ -43,8 +44,4 @@ const LogistrationAlert = ({ intl }) => {
);
};
LogistrationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LogistrationAlert);
export default LogistrationAlert;

View File

@@ -13,18 +13,21 @@ export const DECODE_ROUTES = {
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
'/preview/course/:courseId/:sequenceId/:unitId',
'/preview/course/:courseId/:sequenceId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
};
} as const satisfies Readonly<{ [k: string]: string | readonly string[] }>;
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch?',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
CONSENT: 'consent',
};
} as const satisfies Readonly<{ [k: string]: string }>;
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
@@ -32,7 +35,7 @@ export const REDIRECT_MODES = {
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
};
} as const satisfies Readonly<{ [k: string]: string }>;
export const VERIFIED_MODES = [
'professional',
@@ -43,9 +46,29 @@ export const VERIFIED_MODES = [
'executive-education',
'paid-executive-education',
'paid-bootcamp',
];
] as const satisfies readonly string[];
export const AUDIT_MODES = [
'audit',
'honor',
'unpaid-executive-education',
'unpaid-bootcamp',
] as const satisfies readonly string[];
// In sync with CourseMode.UPSELL_TO_VERIFIED_MODES
// https://github.com/openedx/edx-platform/blob/master/common/djangoapps/course_modes/models.py#L231
export const ALLOW_UPSELL_MODES = [
'audit',
'honor',
] as const satisfies readonly string[];
export const WIDGETS = {
DISCUSSIONS: 'DISCUSSIONS',
NOTIFICATIONS: 'NOTIFICATIONS',
};
} as const satisfies Readonly<{ [k: string]: string }>;
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
export type StatusValue = typeof LOADING | typeof LOADED | typeof FAILED | typeof DENIED;

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tabs, Tab } from '@openedx/paragon';
import { useParams } from 'react-router';
@@ -13,7 +13,8 @@ const filterTypes = ['text', 'video', 'sequence'];
const filterOther = 'other';
const validFilters = [filterAll, ...filterTypes, filterOther];
export const CoursewareSearchResultsFilter = ({ intl }) => {
export const CoursewareSearchResultsFilter = () => {
const intl = useIntl();
const { courseId } = useParams();
const lastSearch = useModel('contentSearchResults', courseId);
const { filter: filterKeyword, setFilter } = useCoursewareSearchParams();
@@ -73,8 +74,4 @@ export const CoursewareSearchResultsFilter = ({ intl }) => {
);
};
CoursewareSearchResultsFilter.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchResultsFilter);
export default CoursewareSearchResultsFilter;

View File

@@ -1,8 +1,8 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { useParams } from 'react-router';
import { useDispatch } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Alert, Button, Icon, Spinner,
} from '@openedx/paragon';
@@ -18,7 +18,8 @@ import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter';
import { updateModel, useModel } from '../../generic/model-store';
import { searchCourseContent } from '../data/thunks';
const CoursewareSearch = ({ intl, ...sectionProps }) => {
const CoursewareSearch = ({ ...sectionProps }) => {
const { formatMessage } = useIntl();
const { courseId } = useParams();
const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams();
const dispatch = useDispatch();
@@ -29,6 +30,7 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
errors,
total,
} = useModel('contentSearchResults', courseId);
const dialogRef = useRef();
useLockScroll();
@@ -44,7 +46,8 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
searchKeyword: '',
results: [],
errors: undefined,
loading: false,
loading:
false,
},
}));
};
@@ -66,20 +69,46 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
setQuery(value);
};
useEffect(() => {
handleSubmit(searchKeyword);
}, []);
const handleOnChange = (value) => {
if (value === searchKeyword) { return; }
if (!value) { clearSearch(); }
};
const handleSearchCloseClick = () => {
const close = () => {
clearSearch();
dispatch(setShowSearch(false));
};
const handlePopState = () => close();
const handleBackdropClick = function (event) {
if (event.target === dialogRef.current) {
dialogRef.current.close();
}
};
useEffect(() => {
// We need this to keep the dialog reference when unmounting.
const dialog = dialogRef.current;
// Open the dialog as a modal on render to confine focus within it.
dialogRef.current.showModal();
if (searchKeyword) {
handleSubmit(searchKeyword); // In case it's opened with a search link, we run the search.
}
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('popstate', handlePopState, { signal });
dialog.addEventListener('click', handleBackdropClick, { signal });
return () => controller.abort(); // Removes event listeners.
}, []);
const handleSearchClose = () => close();
let status = 'idle';
if (loading) {
status = 'loading';
@@ -90,59 +119,58 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
}
return (
<section className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-section" {...sectionProps}>
<div className="courseware-search__close">
<Button
variant="tertiary"
className="p-1"
aria-label={intl.formatMessage(messages.searchCloseAction)}
onClick={handleSearchCloseClick}
data-testid="courseware-search-close-button"
><Icon src={Close} />
</Button>
</div>
<dialog ref={dialogRef} className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-dialog" onClose={handleSearchClose} {...sectionProps}>
<div className="courseware-search__outer-content">
<div className="courseware-search__content">
<h1 className="h2">{intl.formatMessage(messages.searchModuleTitle)}</h1>
<CoursewareSearchForm
searchTerm={searchKeyword}
onSubmit={handleSubmit}
onChange={handleOnChange}
placeholder={intl.formatMessage(messages.searchBarPlaceholderText)}
/>
{status === 'loading' ? (
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
<Spinner animation="border" variant="light" screenReaderText={intl.formatMessage(messages.loading)} />
<div className="courseware-search__content" data-testid="courseware-search-content">
<div className="courseware-search__form">
<h1 className="h2">{formatMessage(messages.searchModuleTitle)}</h1>
<CoursewareSearchForm
searchTerm={searchKeyword}
onSubmit={handleSubmit}
onChange={handleOnChange}
placeholder={formatMessage(messages.searchBarPlaceholderText)}
/>
<div className="courseware-search__close">
<Button
variant="tertiary"
className="p-1"
aria-label={formatMessage(messages.searchCloseAction)}
onClick={() => dialogRef.current.close()}
data-testid="courseware-search-close-button"
><Icon src={Close} />
</Button>
</div>
) : null}
{status === 'error' && (
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
{intl.formatMessage(messages.searchResultsError)}
</Alert>
)}
{status === 'results' ? (
<>
{total > 0 ? (
<div
className="courseware-search__results-summary"
aria-live="polite"
aria-relevant="all"
aria-atomic="true"
data-testid="courseware-search-summary"
>{intl.formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
</div>
) : null}
<CoursewareSearchResultsFilterContainer />
</>
) : null}
</div>
<div className="courseware-search__results" aria-live="polite" data-testid="courseware-search-results">
{status === 'loading' ? (
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
<Spinner animation="border" variant="light" screenReaderText={formatMessage(messages.loading)} />
</div>
) : null}
{status === 'error' && (
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
{formatMessage(messages.searchResultsError)}
</Alert>
)}
{status === 'results' ? (
<>
{total > 0 ? (
<div
className="courseware-search__results-summary"
aria-relevant="all"
aria-atomic="true"
data-testid="courseware-search-summary"
>{formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
</div>
) : null}
<CoursewareSearchResultsFilterContainer />
</>
) : null}
</div>
</div>
</div>
</section>
</dialog>
);
};
CoursewareSearch.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearch);
export default CoursewareSearch;

View File

@@ -9,6 +9,7 @@ import {
screen,
waitFor,
fireEvent,
within,
} from '../../setupTest';
import { CoursewareSearch } from './index';
import { useElementBoundingBox, useLockScroll, useCoursewareSearchParams } from './hooks';
@@ -19,6 +20,7 @@ import { updateModel, useModel } from '../../generic/model-store';
jest.mock('./hooks');
jest.mock('../../generic/model-store', () => ({
...jest.requireActual('../../generic/model-store'),
updateModel: jest.fn(),
useModel: jest.fn(),
}));
@@ -56,7 +58,7 @@ const defaultProps = {
total: 0,
};
const coursewareSearch = {
const defaultSearchParams = {
query: '',
filter: '',
setQuery: jest.fn(),
@@ -96,14 +98,20 @@ const mockModels = ((props = defaultProps) => {
});
});
const mockSearchParams = ((props = coursewareSearch) => {
const mockSearchParams = ((params) => {
const props = { ...defaultSearchParams, ...params };
useCoursewareSearchParams.mockReturnValue(props);
});
describe('CoursewareSearch', () => {
beforeAll(initializeMockApp);
beforeAll(() => initializeMockApp());
beforeEach(() => {
mockModels();
mockSearchParams();
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -113,27 +121,22 @@ describe('CoursewareSearch', () => {
});
it('should use useElementBoundingBox() and useLockScroll() hooks', () => {
mockModels();
mockSearchParams();
renderComponent();
expect(useElementBoundingBox).toBeCalledTimes(1);
expect(useLockScroll).toBeCalledTimes(1);
expect(useElementBoundingBox).toHaveBeenCalledTimes(1);
expect(useLockScroll).toHaveBeenCalledTimes(1);
});
it('should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
mockModels();
mockSearchParams();
renderComponent();
const section = screen.getByTestId('courseware-search-section');
const section = screen.getByTestId('courseware-search-dialog');
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
});
});
describe('when clicking on the "Close" button', () => {
it('should dispatch setShowSearch(false)', async () => {
mockModels();
it('should close the dialog', async () => {
renderComponent();
await waitFor(() => {
@@ -141,7 +144,8 @@ describe('CoursewareSearch', () => {
fireEvent.click(close);
});
expect(setShowSearch).toBeCalledWith(false);
expect(HTMLDialogElement.prototype.close).toHaveBeenCalled();
expect(setShowSearch).toHaveBeenCalledWith(false);
});
});
@@ -149,29 +153,24 @@ describe('CoursewareSearch', () => {
it('should use "--modal-top-position: 0" if nce element is not present', () => {
useElementBoundingBox.mockImplementation(() => undefined);
mockModels();
mockSearchParams();
renderComponent();
const section = screen.getByTestId('courseware-search-section');
const section = screen.getByTestId('courseware-search-dialog');
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
});
});
describe('when passing extra props', () => {
it('should pass on extra props to section element', () => {
mockModels();
mockSearchParams();
renderComponent({ foo: 'bar' });
const section = screen.getByTestId('courseware-search-section');
const section = screen.getByTestId('courseware-search-dialog');
expect(section).toHaveAttribute('foo', 'bar');
});
});
describe('when submitting an empty search', () => {
it('should clear the search by dispatch updateModel', async () => {
mockModels();
renderComponent();
await waitFor(() => {
@@ -203,7 +202,6 @@ describe('CoursewareSearch', () => {
});
it('should call searchCourseContent', async () => {
mockModels();
renderComponent();
const searchKeyword = 'course';
@@ -246,19 +244,23 @@ describe('CoursewareSearch', () => {
expect(screen.queryByTestId('courseware-search-summary')).not.toBeInTheDocument();
});
it('should show a summary for the results', () => {
it('should show a summary for the results within a container with aria-live="polite"', () => {
mockModels({
searchKeyword: 'fubar',
total: 1,
});
renderComponent();
expect(screen.queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
const results = screen.queryByTestId('courseware-search-results');
expect(results).toHaveAttribute('aria-live', 'polite');
expect(within(results).queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
});
});
describe('when clearing the search input', () => {
it('should clear the search by dispatch updateModel', async () => {
mockSearchParams({ query: 'fubar' });
mockModels({
searchKeyword: 'fubar',
total: 2,

View File

@@ -1,15 +1,14 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CoursewareSearchEmpty = ({ intl }) => (
<div className="courseware-search-results">
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
</div>
);
CoursewareSearchEmpty.propTypes = {
intl: intlShape.isRequired,
const CoursewareSearchEmpty = () => {
const intl = useIntl();
return (
<div className="courseware-search-results">
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
</div>
);
};
export default injectIntl(CoursewareSearchEmpty);
export default CoursewareSearchEmpty;

View File

@@ -1,43 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { SearchField } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CoursewareSearchForm = ({
intl,
searchTerm,
onSubmit,
onChange,
placeholder,
}) => (
<SearchField.Advanced
value={searchTerm}
onSubmit={onSubmit}
onChange={onChange}
submitButtonLocation="external"
className="courseware-search-form"
screenReaderText={{
label: intl.formatMessage(messages.searchSubmitLabel),
clearButton: intl.formatMessage(messages.searchClearAction),
submitButton: null, // Remove the sr-only label in the button.
}}
>
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
<SearchField.Label />
<SearchField.Input placeholder={placeholder} autoFocus />
<SearchField.ClearButton />
</div>
<SearchField.SubmitButton
buttonText={intl.formatMessage(messages.searchSubmitLabel)}
}) => {
const { formatMessage } = useIntl();
return (
<SearchField.Advanced
value={searchTerm}
onSubmit={onSubmit}
onChange={onChange}
submitButtonLocation="external"
data-testid="courseware-search-form-submit"
/>
</SearchField.Advanced>
);
className="courseware-search-form"
screenReaderText={{
label: formatMessage(messages.searchSubmitLabel),
clearButton: formatMessage(messages.searchClearAction),
submitButton: null, // Remove the sr-only label in the button.
}}
>
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
<SearchField.Label />
<SearchField.Input placeholder={placeholder} autoFocus />
<SearchField.ClearButton />
</div>
<SearchField.SubmitButton
buttonText={formatMessage(messages.searchSubmitLabel)}
submitButtonLocation="external"
data-testid="courseware-search-form-submit"
/>
</SearchField.Advanced>
);
};
CoursewareSearchForm.propTypes = {
intl: intlShape.isRequired,
searchTerm: PropTypes.string,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
@@ -51,4 +52,4 @@ CoursewareSearchForm.defaultProps = {
placeholder: undefined,
};
export default injectIntl(CoursewareSearchForm);
export default CoursewareSearchForm;

View File

@@ -1,15 +1,14 @@
import React, { useEffect } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@openedx/paragon';
import { Search } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { ManageSearch } from '@openedx/paragon/icons';
import { useDispatch } from 'react-redux';
import messages from './messages';
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
import { setShowSearch } from '../data/slice';
const CoursewareSearchToggle = ({
intl,
}) => {
const CoursewareSearchToggle = () => {
const intl = useIntl();
const dispatch = useDispatch();
const enabled = useCoursewareSearchFeatureFlag();
const { query } = useCoursewareSearchParams();
@@ -25,23 +24,20 @@ const CoursewareSearchToggle = ({
if (!enabled) { return null; }
return (
<div className="courseware-searc-toggle">
<div className="courseware-search-toggle">
<Button
variant="tertiary"
variant="outline-primary"
size="sm"
className="p-1 mt-2 mr-2 rounded-lg"
className="p-1 mt-2 mr-2"
aria-label={intl.formatMessage(messages.searchOpenAction)}
onClick={handleSearchOpenClick}
data-testid="courseware-search-open-button"
iconAfter={ManageSearch}
>
<Icon src={Search} />
{intl.formatMessage(messages.contentSearchButton)}
</Button>
</div>
);
};
CoursewareSearchToggle.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchToggle);
export default CoursewareSearchToggle;

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { fetchCoursewareSearchSettings } from '../data/thunks';
@@ -38,13 +38,13 @@ describe('CoursewareSearch Hooks', () => {
it('should return true if feature is enabled', async () => {
const hook = await renderTestHook();
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
await waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
expect(hook.result.current).toBe(true);
});
it('should return false if feature is disabled', async () => {
const hook = await renderTestHook(false);
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
await waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
expect(hook.result.current).toBe(false);
});
});
@@ -125,7 +125,7 @@ describe('CoursewareSearch Hooks', () => {
it('should return the element bounding box', async () => {
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
hook.waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
await waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
expect(hook.result.current).toEqual(mockedInfo);
});

View File

@@ -2,79 +2,84 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
searchOpenAction: {
id: 'learn.coursewareSerch.openAction',
id: 'learn.coursewareSearch.openAction',
defaultMessage: 'Search within this course',
description: 'Aria-label for a button that will pop up Courseware Search.',
},
contentSearchButton: {
id: 'learn.coursewareSearch.contentSearchButton',
defaultMessage: 'Content search',
description: 'Text for a button that will pop up Courseware Search.',
},
searchSubmitLabel: {
id: 'learn.coursewareSerch.submitLabel',
id: 'learn.coursewareSearch.submitLabel',
defaultMessage: 'Search',
description: 'Button label that will submit Courseware Search.',
},
searchClearAction: {
id: 'learn.coursewareSerch.clearAction',
id: 'learn.coursewareSearch.clearAction',
defaultMessage: 'Clear search',
description: 'Button label that will the current Courseware Search input.',
},
searchCloseAction: {
id: 'learn.coursewareSerch.closeAction',
id: 'learn.coursewareSearch.closeAction',
defaultMessage: 'Close the search form',
description: 'Aria-label for a button that will close Courseware Search.',
},
searchModuleTitle: {
id: 'learn.coursewareSerch.searchModuleTitle',
id: 'learn.coursewareSearch.searchModuleTitle',
defaultMessage: 'Search this course',
description: 'Title for the Courseware Search module.',
},
searchBarPlaceholderText: {
id: 'learn.coursewareSerch.searchBarPlaceholderText',
id: 'learn.coursewareSearch.searchBarPlaceholderText',
defaultMessage: 'Search',
description: 'Placeholder text for the Courseware Search input control',
},
loading: {
id: 'learn.coursewareSerch.loading',
id: 'learn.coursewareSearch.loading',
defaultMessage: 'Searching...',
description: 'Screen reader text to use on the spinner while the search is performing.',
},
searchResultsNone: {
id: 'learn.coursewareSerch.searchResultsNone',
id: 'learn.coursewareSearch.searchResultsNone',
defaultMessage: 'No results found.',
description: 'Text to show when the Courseware Search found no results matching the criteria.',
},
searchResultsLabel: {
id: 'learn.coursewareSerch.searchResultsLabel',
id: 'learn.coursewareSearch.searchResultsLabel',
defaultMessage: 'Results for "{keyword}":',
description: 'Text to show above the search results response list.',
},
searchResultsError: {
id: 'learn.coursewareSerch.searchResultsError',
id: 'learn.coursewareSearch.searchResultsError',
defaultMessage: 'There was an error on the search process. Please try again in a few minutes. If the problem persists, please contact the support team.',
description: 'Error message to show to the users when there\'s an error with the endpoint or the returned payload format.',
},
// These are translations for labeling the filters
'filter:all': {
id: 'learn.coursewareSerch.filter:all',
id: 'learn.coursewareSearch.filter:all',
defaultMessage: 'All content',
description: 'Label for the search results filter that shows all content (no filter).',
},
'filter:text': {
id: 'learn.coursewareSerch.filter:text',
id: 'learn.coursewareSearch.filter:text',
defaultMessage: 'Text',
description: 'Label for the search results filter that shows results with text content.',
},
'filter:video': {
id: 'learn.coursewareSerch.filter:video',
id: 'learn.coursewareSearch.filter:video',
defaultMessage: 'Video',
description: 'Label for the search results filter that shows results with video content.',
},
'filter:sequence': {
id: 'learn.coursewareSerch.filter:sequence',
id: 'learn.coursewareSearch.filter:sequence',
defaultMessage: 'Section',
description: 'Label for the search results filter that shows results with section content.',
},
'filter:other': {
id: 'learn.coursewareSerch.filter:other',
id: 'learn.coursewareSearch.filter:other',
defaultMessage: 'Other',
description: 'Label for the search results filter that shows results with other content.',
},

View File

@@ -31,7 +31,6 @@ Factory.define('outlineTabData')
course_access_redirect: false,
has_scheduled_content: null,
access_expiration: null,
can_show_upgrade_sock: false,
cert_data: {
cert_status: null,
cert_web_view_url: null,

View File

@@ -17,7 +17,21 @@ Factory.define('progressTabData')
percent: 1,
is_passing: true,
},
final_grades: 0.5,
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: [
{
display_name: 'First section',

View File

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

View File

@@ -3,93 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
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(2);
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
* @param metadata the data to normalize
@@ -155,6 +68,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
title: block.display_name,
hideFromTOC: block.hide_from_toc,
navigationDisabled: block.navigation_disabled,
isPreview: block.is_preview,
};
break;
@@ -236,11 +150,6 @@ export async function getProgressTabData(courseId, targetUserId) {
const { data } = await getAuthenticatedHttpClient().get(url);
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.
// 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.
@@ -289,9 +198,17 @@ export async function getProgressTabData(courseId, targetUserId) {
}
export async function getProctoringInfoData(courseId, username) {
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
let url;
if (!getConfig().EXAMS_BASE_URL) {
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
}
} else {
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/course_id/${encodeURIComponent(courseId)}/onboarding`;
if (username) {
url += `?username=${encodeURIComponent(username)}`;
}
}
try {
const { data } = await getAuthenticatedHttpClient().get(url);
@@ -359,7 +276,6 @@ export async function getOutlineTabData(courseId) {
} = tabData;
const accessExpiration = camelCaseObject(data.access_expiration);
const canShowUpgradeSock = data.can_show_upgrade_sock;
const certData = camelCaseObject(data.cert_data);
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
const courseGoals = camelCaseObject(data.course_goals);
@@ -381,7 +297,6 @@ export async function getOutlineTabData(courseId) {
return {
accessExpiration,
canShowUpgradeSock,
certData,
courseBlocks,
courseGoals,
@@ -449,7 +364,7 @@ export async function unsubscribeFromCourseGoal(token) {
.then(res => camelCaseObject(res));
}
export async function getCoursewareSearchEnabledFlag(courseId) {
export async function getCoursewareSearchEnabled(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return { enabled: data.enabled || false };
@@ -465,3 +380,24 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option
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', () => {
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);
});
});
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

@@ -46,7 +46,6 @@ describe('Course Home Service', () => {
willRespondWith: {
status: 200,
body: {
can_show_upgrade_sock: boolean(false),
verified_mode: like({
access_expiration_date: null,
currency: 'USD',
@@ -89,11 +88,11 @@ describe('Course Home Service', () => {
}),
title: string('Demonstration Course'),
username: string('edx'),
has_course_author_access: boolean(true),
},
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
@@ -133,6 +132,7 @@ describe('Course Home Service', () => {
],
title: 'Demonstration Course',
username: 'edx',
hasCourseAuthorAccess: true,
};
const response = getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();

View File

@@ -297,4 +297,178 @@ describe('Data layer integration tests', () => {
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

@@ -1,10 +1,12 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
import {
LOADING,
LOADED,
FAILED,
DENIED,
} from '@src/constants';
const slice = createSlice({
name: 'course-home',
@@ -16,6 +18,7 @@ const slice = createSlice({
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: null,
},
reducers: {
fetchProctoringInfoResolved: (state) => {
@@ -51,6 +54,9 @@ const slice = createSlice({
setShowSearch: (state, { payload }) => {
state.showSearch = payload;
},
setExamsData: (state, { payload }) => {
state.examsData = payload;
},
},
});
@@ -62,6 +68,7 @@ export const {
fetchTabSuccess,
setCallToActionToast,
setShowSearch,
setExamsData,
} = slice.actions;
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,
getCourseHomeCourseMetadata,
getDatesTabData,
getExamsData,
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
@@ -12,7 +13,7 @@ import {
postDismissWelcomeMessage,
postRequestCert,
getLiveTabIframe,
getCoursewareSearchEnabledFlag,
getCoursewareSearchEnabled,
searchCourseContentFromAPI,
} from './api';
@@ -26,6 +27,7 @@ import {
fetchTabRequest,
fetchTabSuccess,
setCallToActionToast,
setExamsData,
} from './slice';
import mapSearchResponse from '../courseware-search/map-search-response';
@@ -159,7 +161,7 @@ export function processEvent(eventData, getTabData) {
export async function fetchCoursewareSearchSettings(courseId) {
try {
const { enabled } = await getCoursewareSearchEnabledFlag(courseId);
const { enabled } = await getCoursewareSearchEnabled(courseId);
return { enabled };
} catch (e) {
return { enabled: false };
@@ -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

@@ -1,7 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './timeline/Timeline';
@@ -14,7 +14,8 @@ import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
const DatesTab = ({ intl }) => {
const DatesTab = () => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -59,8 +60,4 @@ const DatesTab = ({ intl }) => {
);
};
DatesTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DatesTab);
export default DatesTab;

View File

@@ -135,6 +135,7 @@ describe('DatesTab', () => {
});
it('shows extra info', async () => {
const user = userEvent.setup();
const { items } = await getDay('Sat, Aug 17, 2030');
expect(items).toHaveLength(3);
@@ -142,10 +143,12 @@ describe('DatesTab', () => {
const tipText = "ORA Dates are set by the instructor, and can't be changed";
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon);
await waitForElementToBeRemoved(tooltip); // and it's gone again
await user.hover(tipIcon);
screen.getByText(tipText); // now it's there
await user.unhover(tipIcon);
await waitFor(() => {
expect(screen.queryByText(tipText)).toBeNull(); // and it's gone again
});
});
});

View File

@@ -5,8 +5,7 @@ import { useSelector } from 'react-redux';
import {
FormattedDate,
FormattedTime,
injectIntl,
intlShape,
useIntl,
} from '@edx/frontend-platform/i18n';
import { Tooltip, OverlayTrigger } from '@openedx/paragon';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
@@ -20,10 +19,10 @@ import { isLearnerAssignment } from '../utils';
const Day = ({
date,
first,
intl,
items,
last,
}) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -108,7 +107,6 @@ const Day = ({
Day.propTypes = {
date: PropTypes.objectOf(Date).isRequired,
first: PropTypes.bool,
intl: intlShape.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
dateType: PropTypes.string,
@@ -126,4 +124,4 @@ Day.defaultProps = {
last: false,
};
export default injectIntl(Day);
export default Day;

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useParams, generatePath, useNavigate } from 'react-router-dom';
@@ -30,6 +29,4 @@ const DiscussionTab = () => {
);
};
DiscussionTab.propTypes = {};
export default injectIntl(DiscussionTab);
export default DiscussionTab;

View File

@@ -1,16 +1,17 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import HeaderSlot from '../../plugin-slots/HeaderSlot';
import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
const GoalUnsubscribe = ({ intl }) => {
const GoalUnsubscribe = () => {
const intl = useIntl();
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -38,7 +39,7 @@ const GoalUnsubscribe = ({ intl }) => {
return (
<>
<Header showUserDropdown={false} />
<HeaderSlot showUserDropdown={false} />
<main id="main-content" className="container my-5 text-center">
{isLoading && (
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
@@ -51,8 +52,4 @@ const GoalUnsubscribe = ({ intl }) => {
);
};
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GoalUnsubscribe);
export default GoalUnsubscribe;

View File

@@ -1,28 +1,26 @@
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@openedx/paragon';
import messages from './messages';
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
const ResultPage = ({ courseTitle, error, intl }) => {
const errorDescription = (
<FormattedMessage
id="learning.goals.unsubscribe.errorDescription"
defaultMessage="We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help."
values={{
contactSupport: (
<Hyperlink
className="text-reset"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().CONTACT_URL}`}
>
{intl.formatMessage(messages.contactSupport)}
</Hyperlink>
),
}}
/>
const ResultPage = ({ courseTitle, error }) => {
const intl = useIntl();
const errorDescription = intl.formatMessage(
messages.errorDescription,
{
contactSupport: (
<Hyperlink
className="text-reset"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().CONTACT_URL}`}
>
{intl.formatMessage(messages.contactSupport)}
</Hyperlink>
),
},
);
const header = error
@@ -54,7 +52,6 @@ ResultPage.defaultProps = {
ResultPage.propTypes = {
courseTitle: PropTypes.string,
error: PropTypes.bool,
intl: intlShape.isRequired,
};
export default injectIntl(ResultPage);
export default ResultPage;

View File

@@ -16,6 +16,11 @@ const messages = defineMessages({
defaultMessage: 'Something went wrong',
description: 'It indicate that the unsubscribing request has failed',
},
errorDescription: {
id: 'learning.goals.unsubscribe.errorDescription',
defaultMessage: 'We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help.',
description: 'Message that notifies user that unsubscribing failed and to try again',
},
goToDashboard: {
id: 'learning.goals.unsubscribe.goToDashboard',
defaultMessage: 'Go to dashboard',

View File

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

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { CourseOutlineTabNotificationsSlot } from '../../plugin-slots/CourseOutlineTabNotificationsSlot';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
@@ -14,9 +15,7 @@ import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
import CourseTools from './widgets/CourseTools';
import { fetchOutlineTab } from '../data';
import messages from './messages';
import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
import useCourseEndAlert from './alerts/course-end-alert';
@@ -27,8 +26,10 @@ import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
import CourseHomeSectionOutlineSlot from '../../plugin-slots/CourseHomeSectionOutlineSlot';
const OutlineTab = ({ intl }) => {
const OutlineTab = () => {
const intl = useIntl();
const {
courseId,
proctoringPanelStatus,
@@ -38,11 +39,11 @@ const OutlineTab = ({ intl }) => {
isSelfPaced,
org,
title,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const expandButtonRef = useRef();
const {
accessExpiration,
courseBlocks: {
courses,
sections,
@@ -51,20 +52,12 @@ const OutlineTab = ({ intl }) => {
selectedGoal,
weeklyLearningGoalEnabled,
} = {},
datesBannerInfo,
datesWidget: {
courseDateBlocks,
},
enableProctoredExams,
offer,
timeOffsetMillis,
verifiedMode,
} = useModel('outline', courseId);
const {
marketingUrl,
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
@@ -158,32 +151,27 @@ const OutlineTab = ({ intl }) => {
</>
)}
<StartOrResumeCourseCard />
<WelcomeMessage courseId={courseId} />
<WelcomeMessage courseId={courseId} nextElementRef={expandButtonRef} />
{rootCourseId && (
<>
<div className="row w-100 m-0 mb-3 justify-content-end">
<div id="expand-button-row" className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-md-auto p-0">
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
<Button ref={expandButtonRef} variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button>
</div>
</div>
<ol id="courseHome-outline" className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
defaultOpen={sections[sectionId].resumeBlock}
expand={expandAll}
section={sections[sectionId]}
/>
))}
</ol>
<CourseHomeSectionOutlineSlot
expandAll={expandAll}
sectionIds={courses[rootCourseId].sectionIds}
sections={sections}
/>
</>
)}
</div>
{rootCourseId && (
<div className="col col-12 col-md-4">
<CourseOutlineTabNotificationsSlot courseId={courseId} />
<ProctoringInfoPanel />
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as
disabled to avoid components bouncing around too much as screen is rendered */ }
@@ -194,19 +182,6 @@ const OutlineTab = ({ intl }) => {
/>
)}
<CourseTools />
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
<CourseDates />
<CourseHandouts />
</div>
@@ -216,8 +191,4 @@ const OutlineTab = ({ intl }) => {
);
};
OutlineTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(OutlineTab);
export default OutlineTab;

View File

@@ -5,7 +5,7 @@ import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import Cookies from 'js-cookie';
@@ -54,7 +54,7 @@ describe('Outline Tab', () => {
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}&username=MockUser`;
const proctoringInfoUrl = `${getConfig().EXAMS_BASE_URL}/api/v1/student/course_id/${encodeURIComponent(courseId)}/onboarding?username=MockUser`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata');
@@ -132,7 +132,18 @@ describe('Outline Tab', () => {
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
});
it('includes outline_tab_notifications_slot', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTestId('org.openedx.frontend.learning.course_outline_tab_notifications.v1')).toBeInTheDocument();
});
it('handles expand/collapse all button click', async () => {
const user = userEvent.setup();
await fetchAndRender();
// Button renders as "Expand All"
const expandButton = screen.getByRole('button', { name: 'Expand all' });
@@ -143,11 +154,11 @@ describe('Outline Tab', () => {
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
// Click to expand section
userEvent.click(expandButton);
await user.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
// Click to collapse section
userEvent.click(expandButton);
await user.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
});
@@ -157,7 +168,7 @@ describe('Outline Tab', () => {
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
expect(screen.getByLabelText('Completed section')).toBeInTheDocument();
});
it('displays correct icon for incomplete assignment', async () => {
@@ -166,7 +177,7 @@ describe('Outline Tab', () => {
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
expect(screen.getByLabelText('Incomplete section')).toBeInTheDocument();
});
it('SequenceLink displays link', async () => {
@@ -265,21 +276,34 @@ describe('Outline Tab', () => {
});
it('renders show more/less button and handles click', async () => {
const user = userEvent.setup();
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
userEvent.click(showMoreButton);
await user.click(showMoreButton);
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
expect(showLessButton).toBeInTheDocument();
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
userEvent.click(showLessButton);
await user.click(showLessButton);
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
expect(showLessButton).not.toBeInTheDocument();
showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
});
it('dismisses message', async () => {
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
const dismissButton = screen.queryByRole('button', { name: 'Dismiss' });
const expandButton = screen.queryByRole('button', { name: 'Expand all' });
fireEvent.click(dismissButton);
expect(expandButton).toHaveFocus();
expect(screen.queryByText('Welcome Message')).toBeNull();
});
});
it('ignores comments and misformatted HTML', async () => {
@@ -1166,80 +1190,6 @@ describe('Outline Tab', () => {
});
});
describe('Upgrade Card', () => {
it('renders title when upgrade is available', async () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
});
it('displays link to upgrade', async () => {
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
});
it('viewing upgrade card sends analytics', async () => {
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
await fetchAndRender();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('Promotion Viewed', {
org_key: 'edX',
courserun_key: courseId,
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.bi.course.upgrade.sidebarupsell.displayed', {
org_key: 'edX',
courserun_key: courseId,
});
});
it('clicking upgrade link sends analytics', async () => {
await fetchAndRender();
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
const upgradeButton = screen.getByRole('link', { name: 'Upgrade for $149' });
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'Promotion Clicked', {
org_key: 'edX',
courserun_key: courseId,
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
});
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'green_upgrade',
linkName: 'course_home_green',
linkType: 'button',
pageName: 'course_home',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(2);
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(1, 'edx.bi.course.upgrade.sidebarupsell.clicked', {
org_key: 'edX',
courserun_key: courseId,
});
expect(sendTrackingLogEvent).toHaveBeenNthCalledWith(2, 'edx.course.enrollment.upgrade.clicked', {
org_key: 'edX',
courserun_key: courseId,
location: 'sidebar-message',
});
});
});
describe('Account Activation Alert', () => {
beforeEach(() => {
const intersectionObserverMock = () => ({

View File

@@ -1,137 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, IconButton, Icon } from '@openedx/paragon';
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { DisabledVisible } from '@openedx/paragon/icons';
import SequenceLink from './SequenceLink';
import { useModel } from '../../generic/model-store';
import genericMessages from '../../generic/messages';
import messages from './messages';
const Section = ({
courseId,
defaultOpen,
expand,
intl,
section,
}) => {
const {
complete,
sequenceIds,
title,
hideFromTOC,
} = section;
const {
courseBlocks: {
sequences,
},
} = useModel('outline', courseId);
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(expand);
}, [expand]);
useEffect(() => {
setOpen(defaultOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sectionTitle = (
<div className="d-flex row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
icon={fasCheckCircle}
fixedWidth
className="float-left mt-1 text-success"
aria-hidden="true"
title={intl.formatMessage(messages.completedSection)}
/>
) : (
<FontAwesomeIcon
icon={farCheckCircle}
fixedWidth
className="float-left mt-1 text-gray-400"
aria-hidden="true"
title={intl.formatMessage(messages.incompleteSection)}
/>
)}
</div>
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
<span className="align-middle col-6">{title}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
</div>
{hideFromTOC && (
<div className="row">
{hideFromTOC && (
<span className="small d-flex align-content-end">
<Icon className="mr-2" src={DisabledVisible} data-testid="hide-from-toc-section-icon" />
<span data-testid="hide-from-toc-section-text">
{intl.formatMessage(messages.hiddenSection)}
</span>
</span>
)}
</div>
)}
</div>
);
return (
<li>
<Collapsible
className="mb-2"
styling="card-lg"
title={sectionTitle}
open={open}
onToggle={() => { setOpen(!open); }}
iconWhenClosed={(
<IconButton
alt={intl.formatMessage(messages.openSection)}
icon={faPlus}
onClick={() => { setOpen(true); }}
size="sm"
/>
)}
iconWhenOpen={(
<IconButton
alt={intl.formatMessage(genericMessages.close)}
icon={faMinus}
onClick={() => { setOpen(false); }}
size="sm"
/>
)}
>
<ol className="list-unstyled">
{sequenceIds.map((sequenceId, index) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
courseId={courseId}
sequence={sequences[sequenceId]}
first={index === 0}
/>
))}
</ol>
</Collapsible>
</li>
);
};
Section.propTypes = {
courseId: PropTypes.string.isRequired,
defaultOpen: PropTypes.bool.isRequired,
expand: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
section: PropTypes.shape().isRequired,
};
export default injectIntl(Section);

View File

@@ -1,147 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import {
FormattedMessage,
FormattedTime,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Icon } from '@openedx/paragon';
import { Block } from '@openedx/paragon/icons';
import EffortEstimate from '../../shared/effort-estimate';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const SequenceLink = ({
id,
intl,
courseId,
first,
sequence,
}) => {
const {
complete,
description,
due,
showLink,
title,
hideFromTOC,
} = sequence;
const {
userTimezone,
} = useModel('outline', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
const displayTitle = showLink ? coursewareUrl : title;
const dueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-set"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
const noDueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-not-set"
defaultMessage="{description}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
icon={fasCheckCircle}
fixedWidth
className="float-left text-success mt-1"
aria-hidden={complete}
title={intl.formatMessage(messages.completedAssignment)}
/>
) : (
<FontAwesomeIcon
icon={farCheckCircle}
fixedWidth
className="float-left text-gray-400 mt-1"
aria-hidden={complete}
title={intl.formatMessage(messages.incompleteAssignment)}
/>
)}
</div>
<div className="col-10 p-0 ml-3 text-break">
<span className="align-middle">{displayTitle}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
</span>
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
{hideFromTOC && (
<div className="row w-100 my-2 mx-4 pl-3">
<span className="small d-flex">
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
<span data-testid="hide-from-toc-sequence-link-text">
{intl.formatMessage(messages.hiddenSequenceLink)}
</span>
</span>
</div>
)}
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
{due ? dueDateMessage : noDueDateMessage}
</small>
</div>
</div>
</li>
);
};
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
first: PropTypes.bool.isRequired,
sequence: PropTypes.shape().isRequired,
};
export default injectIntl(SequenceLink);

View File

@@ -3,8 +3,7 @@ import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
injectIntl,
intlShape,
useIntl,
} from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@openedx/paragon';
import { useDispatch } from 'react-redux';
@@ -25,7 +24,8 @@ export const CERT_STATUS_TYPE = {
UNVERIFIED: 'unverified',
};
const CertificateStatusAlert = ({ intl, payload }) => {
const CertificateStatusAlert = ({ payload }) => {
const intl = useIntl();
const dispatch = useDispatch();
const {
certificateAvailableDate,
@@ -192,7 +192,6 @@ const CertificateStatusAlert = ({ intl, payload }) => {
};
CertificateStatusAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
certificateAvailableDate: PropTypes.string,
certStatus: PropTypes.string,
@@ -210,4 +209,4 @@ CertificateStatusAlert.propTypes = {
}).isRequired,
};
export default injectIntl(CertificateStatusAlert);
export default CertificateStatusAlert;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Button, Hyperlink } from '@openedx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -14,7 +14,8 @@ import outlineMessages from '../../messages';
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
import { useModel } from '../../../../generic/model-store';
const PrivateCourseAlert = ({ intl, payload }) => {
const PrivateCourseAlert = ({ payload }) => {
const intl = useIntl();
const {
anonymousUser,
canEnroll,
@@ -103,7 +104,6 @@ const PrivateCourseAlert = ({ intl, payload }) => {
};
PrivateCourseAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
anonymousUser: PropTypes.bool,
canEnroll: PropTypes.bool,
@@ -111,4 +111,4 @@ PrivateCourseAlert.propTypes = {
}).isRequired,
};
export default injectIntl(PrivateCourseAlert);
export default PrivateCourseAlert;

View File

@@ -341,6 +341,16 @@ const messages = defineMessages({
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',
},
sequenceDueDate: {
id: 'learning.outline.sequence-due-date-set',
defaultMessage: '{description} due {assignmentDue}',
description: 'Used below an assignment title',
},
sequenceNoDueDate: {
id: 'learning.outline.sequence-due-date-not-set',
defaultMessage: '{description}',
description: 'Used below an assignment title',
},
});
export default messages;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Block } from '@openedx/paragon/icons';
import messages from '../messages';
interface Props {}
const HiddenSequenceLink: React.FC<Props> = () => {
const intl = useIntl();
return (
<div className="row w-100 my-2 mx-4 pl-3">
<span className="small d-flex">
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
<span data-testid="hide-from-toc-sequence-link-text">
{intl.formatMessage(messages.hiddenSequenceLink)}
</span>
</span>
</div>
);
};
export default HiddenSequenceLink;

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, IconButton } from '@openedx/paragon';
import { Minus, Plus } from '@openedx/paragon/icons';
import { useModel } from '../../../generic/model-store';
import genericMessages from '../../../generic/messages';
import { useContextId } from '../../../data/hooks';
import messages from '../messages';
import SectionTitle from './SectionTitle';
import SequenceLink from './SequenceLink';
interface Props {
defaultOpen: boolean;
expand: boolean;
section: {
complete: boolean;
sequenceIds: string[];
title: string;
hideFromTOC: boolean;
};
}
const Section: React.FC<Props> = ({
defaultOpen,
expand,
section,
}) => {
const intl = useIntl();
const courseId = useContextId();
const {
complete,
sequenceIds,
title,
hideFromTOC,
} = section;
const {
courseBlocks: {
sequences,
},
} = useModel('outline', courseId);
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(expand);
}, [expand]);
useEffect(() => {
setOpen(defaultOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<li>
<Collapsible
className="mb-2"
styling="card-lg"
title={<SectionTitle {...{ complete, hideFromTOC, title }} />}
open={open}
onToggle={() => { setOpen(!open); }}
iconWhenClosed={(
<IconButton
alt={intl.formatMessage(messages.openSection)}
iconAs={Plus}
onClick={() => { setOpen(true); }}
size="sm"
/>
)}
iconWhenOpen={(
<IconButton
alt={intl.formatMessage(genericMessages.close)}
iconAs={Minus}
onClick={() => { setOpen(false); }}
size="sm"
/>
)}
>
<ol className="list-unstyled">
{sequenceIds.map((sequenceId, index) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
sequence={sequences[sequenceId]}
first={index === 0}
/>
))}
</ol>
</Collapsible>
</li>
);
};
export default Section;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { CheckCircle, CheckCircleOutline, DisabledVisible } from '@openedx/paragon/icons';
import messages from '../messages';
interface Props {
complete: boolean;
hideFromTOC: boolean;
title: string;
}
const SectionTitle: React.FC<Props> = ({ complete, hideFromTOC, title }) => {
const intl = useIntl();
return (
<div className="d-flex row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<Icon
src={CheckCircle}
className="float-left mt-1 text-success"
aria-hidden="true"
svgAttrs={{ 'aria-label': intl.formatMessage(messages.completedSection) }}
size="sm"
/>
) : (
<Icon
src={CheckCircleOutline}
className="float-left mt-1 text-gray-400"
aria-hidden="true"
svgAttrs={{ 'aria-label': intl.formatMessage(messages.incompleteSection) }}
size="sm"
/>
)}
</div>
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
<span className="align-middle col-6">{title}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
</div>
{hideFromTOC && (
<div className="row">
{hideFromTOC && (
<span className="small d-flex align-content-end">
<Icon className="mr-2" src={DisabledVisible} data-testid="hide-from-toc-section-icon" />
<span data-testid="hide-from-toc-section-text">
{intl.formatMessage(messages.hiddenSection)}
</span>
</span>
)}
</div>
)}
</div>
);
};
export default SectionTitle;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { FormattedTime, useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../generic/model-store';
import { useContextId } from '../../../data/hooks';
import messages from '../messages';
interface Props {
due: string;
id: string;
description: string;
}
const SequenceDueDate: React.FC<Props> = ({
due,
id,
description,
}) => {
const intl = useIntl();
const courseId = useContextId();
let dueDateMessage: string | React.ReactNode = intl.formatMessage(
messages.sequenceNoDueDate,
{ description: description || '' },
);
const {
userTimezone,
} = useModel('outline', courseId);
if (due) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
dueDateMessage = intl.formatMessage(
messages.sequenceDueDate,
{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
},
);
}
return (
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
{dueDateMessage}
</small>
</div>
);
};
export default SequenceDueDate;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import classNames from 'classnames';
import SequenceDueDate from './SequenceDueDate';
import HiddenSequenceLink from './HiddenSequenceLink';
import SequenceTitle from './SequenceTitle';
interface Props {
id: string;
first: boolean;
sequence: {
complete: boolean;
description: string;
due: string;
showLink: boolean;
title: string;
hideFromTOC: boolean;
}
}
const SequenceLink: React.FC<Props> = ({
id,
first,
sequence,
}) => {
const {
complete,
description,
due,
showLink,
title,
hideFromTOC,
} = sequence;
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
<SequenceTitle
{...{
complete,
showLink,
title,
sequence,
id,
}}
/>
{hideFromTOC && (
<HiddenSequenceLink />
)}
<SequenceDueDate {...{ due, id, description }} />
</div>
</li>
);
};
export default SequenceLink;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Link } from 'react-router-dom';
import { Icon } from '@openedx/paragon';
import { CheckCircleOutline, CheckCircle } from '@openedx/paragon/icons';
import EffortEstimate from '../../../shared/effort-estimate';
import messages from '../messages';
import { useContextId } from '../../../data/hooks';
interface Props {
complete: boolean;
showLink: boolean;
title: string;
sequence: object;
id: string;
}
const SequenceTitle: React.FC<Props> = ({
complete,
showLink,
title,
sequence,
id,
}) => {
const intl = useIntl();
const courseId = useContextId();
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
const displayTitle = showLink ? coursewareUrl : title;
return (
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<Icon
src={CheckCircle}
className="float-left text-success mt-1"
aria-hidden={complete}
svgAttrs={{ 'aria-label': intl.formatMessage(messages.completedAssignment) }}
size="sm"
/>
) : (
<Icon
src={CheckCircleOutline}
className="float-left text-gray-400 mt-1"
aria-hidden={complete}
svgAttrs={{ 'aria-label': intl.formatMessage(messages.incompleteAssignment) }}
size="sm"
/>
)}
</div>
<div className="col-10 p-0 ml-3 text-break">
<span className="align-middle">{displayTitle}</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
</span>
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
);
};
export default SequenceTitle;

View File

@@ -1,15 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import DateSummary from '../DateSummary';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const CourseDates = ({
intl,
}) => {
const CourseDates = () => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -40,7 +39,7 @@ const CourseDates = ({
/>
))}
</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)}
</a>
</div>
@@ -48,8 +47,4 @@ const CourseDates = ({
);
};
CourseDates.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseDates);
export default CourseDates;

View File

@@ -1,13 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const CourseHandouts = ({ intl }) => {
const CourseHandouts = () => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -31,8 +32,4 @@ const CourseHandouts = ({ intl }) => {
);
};
CourseHandouts.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseHandouts);
export default CourseHandouts;

View File

@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBookmark, faCertificate, faInfo, faCalendar, faStar,
@@ -14,7 +14,8 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
const CourseTools = ({ intl }) => {
const CourseTools = () => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -81,8 +82,4 @@ const CourseTools = ({ intl }) => {
);
};
CourseTools.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseTools);
export default CourseTools;

View File

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

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
// These flag svgs are derivatives of the Flag icon from paragon
import { ReactComponent as FlagIntenseIcon } from './flag_black.svg';
import { ReactComponent as FlagCasualIcon } from './flag_outline.svg';
@@ -13,8 +13,8 @@ const LearningGoalButton = ({
level,
isSelected,
handleSelect,
intl,
}) => {
const intl = useIntl();
const buttonDetails = {
casual: {
daysPerWeek: 1,
@@ -53,7 +53,6 @@ LearningGoalButton.propTypes = {
level: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
handleSelect: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(LearningGoalButton);
export default LearningGoalButton;

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import camelCase from 'lodash.camelcase';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import messages from '../messages';
@@ -10,7 +10,8 @@ import { getProctoringInfoData } from '../../data/api';
import { fetchProctoringInfoResolved } from '../../data/slice';
import { useModel } from '../../../generic/model-store';
const ProctoringInfoPanel = ({ intl }) => {
const ProctoringInfoPanel = () => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -216,8 +217,4 @@ const ProctoringInfoPanel = ({ intl }) => {
);
};
ProctoringInfoPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ProctoringInfoPanel);
export default ProctoringInfoPanel;

View File

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

View File

@@ -1,13 +1,14 @@
import React from 'react';
import { Button, Card } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const StartOrResumeCourseCard = ({ intl }) => {
const StartOrResumeCourseCard = () => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -62,8 +63,4 @@ const StartOrResumeCourseCard = ({ intl }) => {
);
};
StartOrResumeCourseCard.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(StartOrResumeCourseCard);
export default StartOrResumeCourseCard;

View File

@@ -6,7 +6,7 @@ import { Form, Card, Icon } from '@openedx/paragon';
import { history } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Email } from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';
import messages from '../messages';
@@ -18,8 +18,8 @@ import './FlagButton.scss';
const WeeklyLearningGoalCard = ({
daysPerWeek,
subscribedToReminders,
intl,
}) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -152,11 +152,10 @@ const WeeklyLearningGoalCard = ({
WeeklyLearningGoalCard.propTypes = {
daysPerWeek: PropTypes.number,
subscribedToReminders: PropTypes.bool,
intl: intlShape.isRequired,
};
WeeklyLearningGoalCard.defaultProps = {
daysPerWeek: null,
subscribedToReminders: false,
};
export default injectIntl(WeeklyLearningGoalCard);
export default WeeklyLearningGoalCard;

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { useState, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Button, TransitionReplace } from '@openedx/paragon';
import truncate from 'truncate-html';
@@ -11,11 +11,13 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { dismissWelcomeMessage } from '../../data/thunks';
const WelcomeMessage = ({ courseId, intl }) => {
const WelcomeMessage = ({ courseId, nextElementRef }) => {
const intl = useIntl();
const {
welcomeMessageHtml,
} = useModel('outline', courseId);
const messageBodyRef = useRef();
const [display, setDisplay] = useState(true);
// welcomeMessageHtml can contain comments or malformatted HTML which can impact the length that determines
@@ -49,13 +51,20 @@ const WelcomeMessage = ({ courseId, intl }) => {
dismissible
show={display}
onClose={() => {
nextElementRef.current?.focus();
setDisplay(false);
dispatch(dismissWelcomeMessage(courseId));
}}
className="raised-card"
actions={messageCanBeShortened ? [
<Button
onClick={() => setShowShortMessage(!showShortMessage)}
onClick={() => {
if (showShortMessage) {
messageBodyRef.current?.focus();
}
setShowShortMessage(!showShortMessage);
}}
variant="outline-primary"
>
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
@@ -63,32 +72,34 @@ const WelcomeMessage = ({ courseId, intl }) => {
</Button>,
] : []}
>
<TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}>
{showShortMessage ? (
<LmsHtmlFragment
className="inline-link"
data-testid="short-welcome-message-iframe"
key="short-html"
html={shortWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
) : (
<LmsHtmlFragment
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={cleanedWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
)}
</TransitionReplace>
<div ref={messageBodyRef} tabIndex="-1">
<TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}>
{showShortMessage ? (
<LmsHtmlFragment
className="inline-link"
data-testid="short-welcome-message-iframe"
key="short-html"
html={shortWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
) : (
<LmsHtmlFragment
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={cleanedWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
)}
</TransitionReplace>
</div>
</Alert>
);
};
WelcomeMessage.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
nextElementRef: PropTypes.shape({ current: PropTypes.instanceOf(HTMLInputElement) }),
};
export default injectIntl(WelcomeMessage);
export default WelcomeMessage;

View File

@@ -1,15 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const ProgressHeader = ({ intl }) => {
const ProgressHeader = () => {
const intl = useIntl();
const {
courseId,
targetUserId,
@@ -37,8 +36,4 @@ const ProgressHeader = ({ intl }) => {
);
};
ProgressHeader.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ProgressHeader);
export default ProgressHeader;

View File

@@ -1,27 +1,27 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import CertificateStatus from './certificate-status/CertificateStatus';
import CourseCompletion from './course-completion/CourseCompletion';
import CourseGrade from './grades/course-grade/CourseGrade';
import DetailedGrades from './grades/detailed-grades/DetailedGrades';
import GradeSummary from './grades/grade-summary/GradeSummary';
import ProgressHeader from './ProgressHeader';
import RelatedLinks from './related-links/RelatedLinks';
import React, { useMemo } from 'react';
import { useWindowSize } from '@openedx/paragon';
import { useContextId } from '../../data/hooks';
import { useModel } from '../../generic/model-store';
import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot';
import CourseCompletion from './course-completion/CourseCompletion';
import ProgressHeader from './ProgressHeader';
import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/ProgressTabCertificateStatusMainBodySlot';
import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot';
import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot';
import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot';
import { useGetExamsData } from './hooks';
const ProgressTab = () => {
const {
courseId,
} = useSelector(state => state.courseHome);
const courseId = useContextId();
const { disableProgressGraph, sectionScores } = useModel('progress', courseId);
const {
gradesFeatureIsFullyLocked, disableProgressGraph,
} = useModel('progress', courseId);
const sequenceIds = useMemo(() => (
sectionScores.flatMap((section) => (section.subsections)).map((subsection) => subsection.blockKey)
), [sectionScores]);
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
useGetExamsData(courseId, sequenceIds);
const windowWidth = useWindowSize().width;
if (windowWidth === undefined) {
@@ -31,7 +31,6 @@ const ProgressTab = () => {
return null;
}
const wideScreen = windowWidth >= breakpoints.large.minWidth;
return (
<>
<ProgressHeader />
@@ -39,18 +38,15 @@ const ProgressTab = () => {
{/* Main body */}
<div className="col-12 col-md-8 p-0">
{!disableProgressGraph && <CourseCompletion />}
{!wideScreen && <CertificateStatus />}
<CourseGrade />
<div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
<GradeSummary />
<DetailedGrades />
</div>
<ProgressTabCertificateStatusMainBodySlot />
<ProgressTabCourseGradeSlot />
<ProgressTabGradeBreakdownSlot />
</div>
{/* Side panel */}
<div className="col-12 col-md-4 p-0 px-md-4">
{wideScreen && <CertificateStatus />}
<RelatedLinks />
<ProgressTabCertificateStatusSidePanelSlot />
<ProgressTabRelatedLinksSlot />
</div>
</div>
</>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@openedx/paragon';
@@ -111,7 +111,7 @@ describe('Progress Tab', () => {
await fetchAndRender();
sendTrackEvent.mockClear();
const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' });
const outlineTabLink = screen.getAllByRole('link', { name: 'Course outline' });
fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
@@ -471,9 +471,12 @@ describe('Progress Tab', () => {
await fetchAndRender();
expect(screen.getByText('limited feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to work towards a certificate.')).toBeInTheDocument();
expect(screen.queryAllByText('You have limited access to graded assignments as part of the audit track in this course.')).toHaveLength(2);
expect(screen.queryAllByText(
'You have limited access to graded assignments as part of the audit track in this course.',
{ exact: false },
)).toHaveLength(2);
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
expect(screen.queryAllByTestId('locked-icon')).toHaveLength(4);
});
it('does not render subsections for which showGrades is false', async () => {
@@ -545,6 +548,111 @@ describe('Progress Tab', () => {
await fetchAndRender();
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
});
it('does not render ungraded subsections when SHOW_UNGRADED_ASSIGNMENT_PROGRESS is false', async () => {
// The second assignment has has_graded_assignment set to false, so it should not be shown.
setTabData({
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
{
display_name: 'Second section',
subsections: [
{
assignment_type: 'Homework',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: false,
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('First subsection')).toBeInTheDocument();
expect(screen.queryByText('Second subsection')).not.toBeInTheDocument();
});
it('renders both graded and ungraded subsections when SHOW_UNGRADED_ASSIGNMENT_PROGRESS is true', async () => {
// The second assignment has has_graded_assignment set to false.
setConfig({
...getConfig(),
SHOW_UNGRADED_ASSIGNMENT_PROGRESS: true,
});
setTabData({
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
{
display_name: 'Second section',
subsections: [
{
assignment_type: 'Homework',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: false,
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('First subsection')).toBeInTheDocument();
expect(screen.getByText('Second subsection')).toBeInTheDocument();
// reset config for other tests
setConfig({
...getConfig(),
SHOW_UNGRADED_ASSIGNMENT_PROGRESS: false,
});
});
});
describe('Grade Summary', () => {
@@ -553,143 +661,133 @@ describe('Progress Tab', () => {
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({
grading_policy: {
assignment_policies: [],
grade_range: {
pass: 0.75,
},
},
section_scores: [],
assignment_type_grade_summary: [],
});
await fetchAndRender();
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({
grading_policy: {
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_total: 1,
short_label: 'Ex',
type: 'Exam',
weight: 0.5,
short_label: 'Final',
type: 'Final Exam',
weight: 1,
},
],
grade_range: {
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();
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 50% 100% 50%' })).toBeInTheDocument();
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
// Should show lock icon for grade and weighted grade
expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
});
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 () => {
@@ -788,7 +886,7 @@ describe('Progress Tab', () => {
sendTrackEvent.mockClear();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0];
const outlineLink = screen.getAllByRole('link', { name: 'Course outline' })[0];
fireEvent.click(outlineLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
@@ -809,7 +907,7 @@ describe('Progress Tab', () => {
// Open the problem score drawer
fireEvent.click(problemScoreDrawerToggle);
expect(screen.getByText('Problem Scores:')).toBeInTheDocument();
expect(screen.getAllByText('Graded Scores:').length).toBeGreaterThan(1);
expect(screen.getAllByText('0/1')).toHaveLength(3);
});
@@ -821,6 +919,14 @@ describe('Progress Tab', () => {
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
expect(screen.getByText('You currently have no graded problem scores.')).toBeInTheDocument();
});
it('renders Detailed Grades table when section scores are populated', async () => {
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
expect(screen.getByText('First subsection'));
expect(screen.getByText('Second subsection'));
});
});
describe('Certificate Status', () => {
@@ -1384,4 +1490,287 @@ describe('Progress Tab', () => {
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

@@ -1,23 +1,26 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Button, Card } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useContextId } from '../../../data/hooks';
import { useModel } from '../../../generic/model-store';
import { COURSE_EXIT_MODES, getCourseExitMode } from '../../../courseware/course/course-exit/utils';
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
import { requestCert } from '../../data/thunks';
import messages from './messages';
import ProgressCertificateStatusSlot from '../../../plugin-slots/ProgressCertificateStatusSlot';
const CertificateStatus = () => {
const intl = useIntl();
const courseId = useContextId();
const CertificateStatus = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
entranceExamData,
} = useModel('coursewareMeta', courseId);
const {
isEnrolled,
@@ -42,6 +45,8 @@ const CertificateStatus = ({ intl }) => {
certificateAvailableDate,
} = certificateData || {};
const entranceExamPassed = entranceExamData?.entranceExamPassed ?? null;
const mode = getCourseExitMode(
certificateData,
hasScheduledContent,
@@ -49,6 +54,7 @@ const CertificateStatus = ({ intl }) => {
userHasPassingGrade,
null, // CourseExitPageIsActive
canViewCertificate,
entranceExamPassed,
);
const eventProperties = {
@@ -208,7 +214,6 @@ const CertificateStatus = ({ intl }) => {
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!certCase) {
return null;
}
@@ -236,32 +241,32 @@ const CertificateStatus = ({ intl }) => {
return (
<section data-testid="certificate-status-component" className="text-dark-700 mb-4">
<Card className="bg-light-200 raised-card">
<Card.Header title={header} />
<Card.Section className="small text-gray-700">
{body}
</Card.Section>
<Card.Footer>
{buttonText && (buttonLocation || buttonAction) && (
<Button
variant="outline-brand"
onClick={() => {
logCertificateStatusButtonClicked(certStatus);
if (buttonAction) { buttonAction(); }
}}
href={buttonLocation}
block
>
{buttonText}
</Button>
)}
</Card.Footer>
<ProgressCertificateStatusSlot courseId={courseId}>
<div id={`${certCase}_certificate_status`}>
<Card.Header title={header} />
<Card.Section className="small text-gray-700">
{body}
</Card.Section>
<Card.Footer>
{buttonText && (buttonLocation || buttonAction) && (
<Button
variant="outline-brand"
onClick={() => {
logCertificateStatusButtonClicked(certStatus);
if (buttonAction) { buttonAction(); }
}}
href={buttonLocation}
block
>
{buttonText}
</Button>
)}
</Card.Footer>
</div>
</ProgressCertificateStatusSlot>
</Card>
</section>
);
};
CertificateStatus.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CertificateStatus);
export default CertificateStatus;

View File

@@ -1,12 +1,13 @@
import React, { useState } from 'react';
import { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import messages from './messages';
const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) => {
const CompleteDonutSegment = ({ completePercentage, lockedPercentage }) => {
const intl = useIntl();
const [showCompletePopover, setShowCompletePopover] = useState(false);
if (!completePercentage) {
@@ -82,8 +83,7 @@ const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) =>
CompleteDonutSegment.propTypes = {
completePercentage: PropTypes.number.isRequired,
intl: intlShape.isRequired,
lockedPercentage: PropTypes.number.isRequired,
};
export default injectIntl(CompleteDonutSegment);
export default CompleteDonutSegment;

View File

@@ -1,8 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
import { useContextId } from '../../../data/hooks';
import { useModel } from '../../../generic/model-store';
import CompleteDonutSegment from './CompleteDonutSegment';
@@ -10,10 +7,9 @@ import IncompleteDonutSegment from './IncompleteDonutSegment';
import LockedDonutSegment from './LockedDonutSegment';
import messages from './messages';
const CompletionDonutChart = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const CompletionDonutChart = () => {
const intl = useIntl();
const courseId = useContextId();
const {
completionSummary: {
@@ -62,8 +58,4 @@ const CompletionDonutChart = ({ intl }) => {
);
};
CompletionDonutChart.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CompletionDonutChart);
export default CompletionDonutChart;

View File

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

View File

@@ -1,27 +1,26 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import CompletionDonutChart from './CompletionDonutChart';
import messages from './messages';
const CourseCompletion = ({ intl }) => (
<section className="text-dark-700 mb-4 rounded raised-card p-4">
<div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0">
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
<p className="small">
{intl.formatMessage(messages.completionBody)}
</p>
</div>
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
<CompletionDonutChart />
</div>
</div>
</section>
);
const CourseCompletion = () => {
const intl = useIntl();
CourseCompletion.propTypes = {
intl: intlShape.isRequired,
return (
<section className="text-dark-700 mb-4 rounded raised-card p-4">
<div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0">
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
<p className="small">
{intl.formatMessage(messages.completionBody)}
</p>
</div>
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
<CompletionDonutChart />
</div>
</div>
</section>
);
};
export default injectIntl(CourseCompletion);
export default CourseCompletion;

View File

@@ -1,12 +1,13 @@
import React, { useState } from 'react';
import { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import messages from './messages';
const IncompleteDonutSegment = ({ incompletePercentage, intl }) => {
const IncompleteDonutSegment = ({ incompletePercentage }) => {
const intl = useIntl();
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
if (!incompletePercentage) {
@@ -53,7 +54,6 @@ const IncompleteDonutSegment = ({ incompletePercentage, intl }) => {
IncompleteDonutSegment.propTypes = {
incompletePercentage: PropTypes.number.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(IncompleteDonutSegment);
export default IncompleteDonutSegment;

View File

@@ -1,12 +1,13 @@
import React, { useState } from 'react';
import { useState } from 'react';
import PropTypes from 'prop-types';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const LockedDonutSegment = ({ intl, lockedPercentage }) => {
const LockedDonutSegment = ({ lockedPercentage }) => {
const intl = useIntl();
const [showLockedPopover, setShowLockedPopover] = useState(false);
if (!lockedPercentage) {
@@ -65,8 +66,7 @@ const LockedDonutSegment = ({ intl, lockedPercentage }) => {
};
LockedDonutSegment.propTypes = {
intl: intlShape.isRequired,
lockedPercentage: PropTypes.number.isRequired,
};
export default injectIntl(LockedDonutSegment);
export default LockedDonutSegment;

View File

@@ -1,19 +1,17 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { CheckCircle, WarningFilled, WatchFilled } from '@openedx/paragon/icons';
import { Hyperlink, Icon } from '@openedx/paragon';
import { useContextId } from '../../../data/hooks';
import { useModel } from '../../../generic/model-store';
import { DashboardLink } from '../../../shared/links';
import messages from './messages';
const CreditInformation = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const CreditInformation = () => {
const intl = useIntl();
const courseId = useContextId();
const {
creditCourseRequirements,
@@ -36,36 +34,13 @@ const CreditInformation = ({ intl }) => {
switch (creditCourseRequirements.eligibilityStatus) {
case 'not_eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditNotEligible"
defaultMessage="You are no longer eligible for credit in this course. Learn more about {creditLink}."
description="Message to learner who are not eligible for course credit, it can because the a requirement deadline have passed"
values={{ creditLink }}
/>
);
eligibilityStatus = intl.formatMessage(messages.creditNotEligibleStatus, { creditLink });
break;
case 'eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditEligible"
defaultMessage="
You have met the requirements for credit in this course. Go to your
{dashboardLink} to purchase course credit. Or learn more about {creditLink}."
description="After the credit requirements are met, leaners can then do the last step which purchasing the credit. Note that is only doable for leaners after they met all the requirements"
values={{ dashboardLink, creditLink }}
/>
);
eligibilityStatus = intl.formatMessage(messages.creditEligibleStatus, { dashboardLink, creditLink });
break;
case 'partial_eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditPartialEligible"
defaultMessage="You have not yet met the requirements for credit. Learn more about {creditLink}."
description="This means that one or more requirements is not satisfied yet"
values={{ creditLink }}
/>
);
eligibilityStatus = intl.formatMessage(messages.creditPartialEligibleStatus, { creditLink });
break;
default:
break;
@@ -108,8 +83,4 @@ const CreditInformation = ({ intl }) => {
);
};
CreditInformation.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CreditInformation);
export default CreditInformation;

View File

@@ -35,6 +35,22 @@ const messages = defineMessages({
defaultMessage: 'Verification submitted',
description: 'It indicate that the learner submitted a requirement but is not graded or reviewed yet',
},
creditNotEligibleStatus: {
id: 'progress.creditInformation.creditNotEligible',
defaultMessage: 'You are no longer eligible for credit in this course. Learn more about {creditLink}.',
description: 'Message to learner who are not eligible for course credit, it can be that a requirement deadline has passed',
},
creditEligibleStatus: {
id: 'progress.creditInformation.creditEligible',
defaultMessage: `You have met the requirements for credit in this course. Go to your
{dashboardLink} to purchase course credit. Or learn more about {creditLink}.`,
description: 'After the credit requirements are met, leaners can then do the last step which purchasing the credit. Note that is only doable for leaners after they met all the requirements',
},
creditPartialEligibleStatus: {
id: 'progress.creditInformation.creditPartialEligible',
defaultMessage: 'You have not yet met the requirements for credit. Learn more about {creditLink}.',
description: 'This means that one or more requirements is not satisfied yet',
},
});
export default messages;

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