Compare commits

...

109 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
258 changed files with 10272 additions and 7393 deletions

4
.env
View File

@@ -12,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=''
@@ -49,3 +51,5 @@ 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

@@ -8,14 +8,16 @@ 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=''
@@ -51,3 +53,5 @@ 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

@@ -8,14 +8,16 @@ 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=''
@@ -48,3 +50,5 @@ 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'

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

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

View File

@@ -41,9 +41,8 @@ Cloning and Setup
git clone https://github.com/openedx/frontend-app-learning.git
2. Use node v20.x.
2. Use the version of Node specified in ``.nvmrc``.
The current version of the micro-frontend build scripts supports node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
@@ -131,7 +130,7 @@ 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
=======
@@ -145,7 +144,7 @@ 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:

9112
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,11 @@
"i18n_extract": "fedx-scripts formatjs extract",
"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 .",
"postinstall": "patch-package",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"dev": "PUBLIC_PATH=/learning/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "fedx-scripts jest --coverage --passWithNoTests",
"test": "NODE_ENV=test fedx-scripts jest --coverage --passWithNoTests",
"test:watch": "fedx-scripts jest --watch --passWithNoTests",
"types": "tsc --noEmit"
},
@@ -35,20 +35,20 @@
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-header": "^5.8.0",
"@edx/frontend-lib-learning-assistant": "^2.19.2",
"@edx/frontend-lib-special-exams": "^3.1.3",
"@edx/frontend-platform": "^8.0.0",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "3.0.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-build": "^14.2.0",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/frontend-slot-footer": "^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.9.7",
"buffer": "^6.0.3",
@@ -58,12 +58,11 @@
"js-cookie": "3.0.5",
"lodash": "^4.17.21",
"lodash.camelcase": "4.3.0",
"patch-package": "^8.0.0",
"postcss-loader": "^8.1.1",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
"react": "17.0.2",
"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",
@@ -79,9 +78,8 @@
"devDependencies": {
"@edx/reactifex": "2.2.0",
"@pact-foundation/pact": "^13.0.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/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",
@@ -95,7 +93,7 @@
"files": [
{
"path": "dist/*.js",
"maxSize": "1400kB"
"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

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

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

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,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,5 +1,5 @@
import React, { useEffect } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { ManageSearch } from '@openedx/paragon/icons';
import { useDispatch } from 'react-redux';
@@ -7,9 +7,8 @@ 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();
@@ -41,8 +40,4 @@ const CoursewareSearchToggle = ({
);
};
CoursewareSearchToggle.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchToggle);
export default CoursewareSearchToggle;

View File

@@ -9,8 +9,8 @@
height: 100%;
max-width: none;
margin: 0;
border-top: 1px solid $light-300;
z-index: $zindex-modal; // Bootstrap's z-index layer for Modals.
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;
@@ -47,7 +47,7 @@
&__results-summary {
font-size: .9rem;
color: $gray-500;
color: var(--pgn-color-gray-500);
padding: 1rem 0 .5rem;
}
@@ -62,7 +62,7 @@
margin-top: 1.5rem;
&__empty {
color: $gray-500;
color: var(--pgn-color-gray-500);
padding: 6rem 0;
text-align: center;
}
@@ -76,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 {
@@ -99,7 +99,7 @@
align-items: center;
line-height: 2.5;
font-size: 0.875rem;
color: $black;
color: var(--pgn-color-black);
> span {
display: block;
@@ -113,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;
@@ -125,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;
@@ -156,14 +156,14 @@
}
.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')) {
@media (--pgn-size-breakpoint-min-width-md) {
.courseware-search {
&__close {
right: -2.5rem;

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

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

@@ -5,6 +5,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
@@ -397,6 +398,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
@@ -489,7 +491,6 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"outline": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"canShowUpgradeSock": false,
"certData": {
"certStatus": null,
"certWebViewUrl": null,
@@ -529,6 +530,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"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,
@@ -670,6 +672,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
@@ -762,6 +765,19 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"progress": {
"course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null,
"assignmentTypeGradeSummary": [
{
"averageGrade": 1,
"hasHiddenContribution": "none",
"lastGradePublishDate": null,
"numDroppable": 1,
"numTotal": 2,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"certificateData": {},
"completionSummary": {
"completeCount": 1,
@@ -777,17 +793,17 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"finalGrades": 0.5,
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": {
"assignmentPolicies": [
{
"averageGrade": "1.0000",
"numDroppable": 1,
"numTotal": 2,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"gradeRange": {

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(4);
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
};
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
const gradeByAssignmentType = {};
assignmentPolicies.forEach(assignment => {
// Create an array with the number of total assignments and set the scores to 0
// as placeholders for assignments that have not yet been released
gradeByAssignmentType[assignment.type] = {
grades: Array(assignment.numTotal).fill(0),
numAssignmentsCreated: 0,
numTotalExpectedAssignments: assignment.numTotal,
};
});
sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
return;
}
const {
assignmentType,
numPointsEarned,
numPointsPossible,
} = subsection;
// If a subsection's assignment type does not match an assignment policy in Studio,
// we won't be able to include it in this accumulation of grades by assignment type.
// This may happen if a course author has removed/renamed an assignment policy in Studio and
// neglected to update the subsection's of that assignment type
if (!gradeByAssignmentType[assignmentType]) {
return;
}
let {
numAssignmentsCreated,
} = gradeByAssignmentType[assignmentType];
numAssignmentsCreated++;
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
// of expected assignments
gradeByAssignmentType[assignmentType].grades.shift();
}
// Add the graded assignment to the list
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
// Record the created assignment
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
});
});
return assignmentPolicies.map((assignment) => {
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
gradeByAssignmentType[assignment.type].grades,
assignment.weight,
assignment.numDroppable,
);
return {
averageGrade,
numDroppable: assignment.numDroppable,
shortLabel: assignment.shortLabel,
type: assignment.type,
weight: assignment.weight,
weightedGrade,
};
});
}
/**
* Tweak the metadata for consistency
* @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.
@@ -367,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);
@@ -389,7 +297,6 @@ export async function getOutlineTabData(courseId) {
return {
accessExpiration,
canShowUpgradeSock,
certData,
courseBlocks,
courseGoals,
@@ -473,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',
@@ -94,7 +93,6 @@ describe('Course Home Service', () => {
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',

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

@@ -18,6 +18,7 @@ const slice = createSlice({
toastBodyLink: null,
toastHeader: '',
showSearch: false,
examsData: null,
},
reducers: {
fetchProctoringInfoResolved: (state) => {
@@ -53,6 +54,9 @@ const slice = createSlice({
setShowSearch: (state, { payload }) => {
state.showSearch = payload;
},
setExamsData: (state, { payload }) => {
state.examsData = payload;
},
},
});
@@ -64,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,
@@ -26,6 +27,7 @@ import {
fetchTabRequest,
fetchTabSuccess,
setCallToActionToast,
setExamsData,
} from './slice';
import mapSearchResponse from '../courseware-search/map-search-response';
@@ -223,3 +225,19 @@ export function searchCourseContent(courseId, searchKeyword) {
});
};
}
export function fetchExamAttemptsData(courseId, sequenceIds) {
return async (dispatch) => {
const results = await Promise.all(sequenceIds.map(async (sequenceId) => {
try {
const response = await getExamsData(courseId, sequenceId);
return response.exam || {};
} catch (e) {
logError(e);
return {};
}
}));
dispatch(setExamsData(results));
};
}

View File

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

@@ -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,7 +1,7 @@
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 HeaderSlot from '../../plugin-slots/HeaderSlot';
import PageLoading from '../../generic/PageLoading';
@@ -10,7 +10,8 @@ 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);
@@ -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

@@ -5,7 +5,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { CourseOutlineTabNotificationsSlot } from '../../plugin-slots/CourseOutlineTabNotificationsSlot';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
@@ -16,7 +16,6 @@ import CourseTools from './widgets/CourseTools';
import { fetchOutlineTab } from '../data';
import messages from './messages';
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';
@@ -40,13 +39,11 @@ const OutlineTab = () => {
isSelfPaced,
org,
title,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const expandButtonRef = useRef();
const {
accessExpiration,
courseBlocks: {
courses,
sections,
@@ -55,20 +52,12 @@ const OutlineTab = () => {
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();
@@ -182,6 +171,7 @@ const OutlineTab = () => {
</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 */ }
@@ -192,27 +182,6 @@ const OutlineTab = () => {
/>
)}
<CourseTools />
<PluginSlot
id="outline_tab_notifications_slot"
pluginProps={{
courseId,
model: 'outline',
}}
>
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
</PluginSlot>
<CourseDates />
<CourseHandouts />
</div>

View File

@@ -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';
@@ -139,7 +139,7 @@ describe('Outline Tab', () => {
});
await fetchAndRender();
expect(screen.getByTestId('outline_tab_notifications_slot')).toBeInTheDocument();
expect(screen.getByTestId('org.openedx.frontend.learning.course_outline_tab_notifications.v1')).toBeInTheDocument();
});
it('handles expand/collapse all button click', async () => {
@@ -1190,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

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

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

View File

@@ -661,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 () => {
@@ -1500,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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Alert,
Button,
@@ -14,7 +14,8 @@ import { resetDeadlines } from '../data';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const ShiftDatesAlert = ({ fetch, intl, model }) => {
const ShiftDatesAlert = ({ fetch, model }) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -59,8 +60,7 @@ const ShiftDatesAlert = ({ fetch, intl, model }) => {
ShiftDatesAlert.propTypes = {
fetch: PropTypes.func.isRequired,
intl: intlShape.isRequired,
model: PropTypes.string.isRequired,
};
export default injectIntl(ShiftDatesAlert);
export default ShiftDatesAlert;

View File

@@ -1,16 +1,15 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const SuggestedScheduleHeader = ({ intl }) => (
<p className="large">
{intl.formatMessage(messages.suggestedSchedule)}
</p>
);
SuggestedScheduleHeader.propTypes = {
intl: intlShape.isRequired,
const SuggestedScheduleHeader = () => {
const intl = useIntl();
return (
<p className="large">
{intl.formatMessage(messages.suggestedSchedule)}
</p>
);
};
export default injectIntl(SuggestedScheduleHeader);
export default SuggestedScheduleHeader;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Alert,
Button,
@@ -12,7 +12,8 @@ import {
import { useModel } from '../../generic/model-store';
import messages from './messages';
const UpgradeToCompleteAlert = ({ intl, logUpgradeLinkClick }) => {
const UpgradeToCompleteAlert = ({ logUpgradeLinkClick }) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -33,7 +34,7 @@ const UpgradeToCompleteAlert = ({ intl, logUpgradeLinkClick }) => {
}
return (
<Alert className="bg-light-200">
<Alert id="upgrade-complete-alert" className="bg-light-200">
<Row className="w-100 m-0">
<Col xs={12} md={9} className="small p-0 pr-md-2">
<Alert.Heading>{intl.formatMessage(messages.upgradeToCompleteHeader)}</Alert.Heading>
@@ -58,7 +59,6 @@ const UpgradeToCompleteAlert = ({ intl, logUpgradeLinkClick }) => {
};
UpgradeToCompleteAlert.propTypes = {
intl: intlShape.isRequired,
logUpgradeLinkClick: PropTypes.func,
};
@@ -66,4 +66,4 @@ UpgradeToCompleteAlert.defaultProps = {
logUpgradeLinkClick: () => {},
};
export default injectIntl(UpgradeToCompleteAlert);
export default UpgradeToCompleteAlert;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Alert,
Button,
@@ -13,7 +13,8 @@ import {
import { useModel } from '../../generic/model-store';
import messages from './messages';
const UpgradeToShiftDatesAlert = ({ intl, logUpgradeLinkClick, model }) => {
const UpgradeToShiftDatesAlert = ({ logUpgradeLinkClick, model }) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -35,7 +36,7 @@ const UpgradeToShiftDatesAlert = ({ intl, logUpgradeLinkClick, model }) => {
}
return (
<Alert className="bg-light-200">
<Alert id="upgrade-shift-dates-alert" className="bg-light-200">
<Row className="w-100 m-0">
<Col xs={12} md={9} className="small p-0 pr-md-2">
<strong>{intl.formatMessage(messages.missedDeadlines)}</strong>
@@ -60,7 +61,6 @@ const UpgradeToShiftDatesAlert = ({ intl, logUpgradeLinkClick, model }) => {
};
UpgradeToShiftDatesAlert.propTypes = {
intl: intlShape.isRequired,
logUpgradeLinkClick: PropTypes.func,
model: PropTypes.string.isRequired,
};
@@ -69,4 +69,4 @@ UpgradeToShiftDatesAlert.defaultProps = {
logUpgradeLinkClick: () => {},
};
export default injectIntl(UpgradeToShiftDatesAlert);
export default UpgradeToShiftDatesAlert;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import messages from './messages';
@@ -9,8 +9,9 @@ import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/coursew
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
const CourseTabsNavigation = ({
activeTabSlug, className, tabs, intl,
activeTabSlug, className, tabs,
}) => {
const intl = useIntl();
const { show } = useCoursewareSearchState();
return (
@@ -51,7 +52,6 @@ CourseTabsNavigation.propTypes = {
slug: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})).isRequired,
intl: intlShape.isRequired,
};
CourseTabsNavigation.defaultProps = {
@@ -59,4 +59,4 @@ CourseTabsNavigation.defaultProps = {
className: null,
};
export default injectIntl(CourseTabsNavigation);
export default CourseTabsNavigation;

View File

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

View File

@@ -2,7 +2,7 @@ import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom/extend-expect';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import React from 'react';
import {
@@ -221,7 +221,7 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -244,7 +244,7 @@ describe('CoursewareContainer', () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -294,7 +294,7 @@ describe('CoursewareContainer', () => {
describe('when the URL does not contain a unit ID', () => {
it('should choose a unit within the section\'s first sequence', async () => {
setUrl(sectionTree[1].id);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
@@ -369,7 +369,7 @@ describe('CoursewareContainer', () => {
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -388,7 +388,7 @@ describe('CoursewareContainer', () => {
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -405,7 +405,7 @@ describe('CoursewareContainer', () => {
it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -421,7 +421,7 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import { breakpoints, useWindowSize } from '@openedx/paragon';
import { AlertList } from '@src/generic/user-messages';
import { useModel } from '@src/generic/model-store';
import { getCoursewareOutlineSidebarSettings } from '../data/selectors';
import Chat from './chat/Chat';
import { LearnerToolsSlot } from '../../plugin-slots/LearnerToolsSlot';
import SidebarProvider from './sidebar/SidebarContextProvider';
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot';
@@ -62,7 +62,7 @@ const Course = ({
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
);
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
const shouldDisplayLearnerTools = windowWidth >= breakpoints.medium.minWidth;
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
useEffect(() => {
@@ -95,17 +95,13 @@ const Course = ({
/>
</>
)}
{shouldDisplayChat && (
<>
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
courseId={courseId}
contentToolsEnabled={course.showCalculator || course.notes.enabled}
unitId={unitId}
/>
</>
{shouldDisplayLearnerTools && (
<LearnerToolsSlot
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
courseId={courseId}
unitId={unitId}
/>
)}
<div className="w-100 d-flex align-items-center">
<CourseOutlineMobileSidebarTriggerSlot />

View File

@@ -13,17 +13,25 @@ import Course from './Course';
import setupDiscussionSidebar from './test-utils';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
const mockChatTestId = 'fake-chat';
jest.mock('@edx/frontend-lib-special-exams', () => {
const actual = jest.requireActual('@edx/frontend-lib-special-exams');
return {
...actual,
__esModule: true,
// Mock the default export (SequenceExamWrapper) to just render children
// eslint-disable-next-line react/prop-types
default: ({ children }) => <div data-testid="sequence-exam-wrapper">{children}</div>,
};
});
const mockLearnerToolsTestId = 'fake-learner-tools';
jest.mock(
'./chat/Chat',
// eslint-disable-next-line react/prop-types
() => function ({ courseId }) {
return <div className="fake-chat" data-testid={mockChatTestId}>Chat contents {courseId} </div>;
},
'../../plugin-slots/LearnerToolsSlot',
() => ({
// eslint-disable-next-line react/prop-types
LearnerToolsSlot({ courseId }) {
return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
},
}),
);
const recordFirstSectionCelebration = jest.fn();
@@ -50,7 +58,11 @@ describe('Course', () => {
global.innerWidth = breakpoints.extraLarge.minWidth;
});
it('loads learning sequence', async () => {
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('loads learning sequence', () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
waitFor(() => {
@@ -94,7 +106,11 @@ describe('Course', () => {
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
});
it('displays first section celebration modal', async () => {
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('displays first section celebration modal', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { firstSection: true } });
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
const { courseware, models } = testStore.getState();
@@ -116,7 +132,11 @@ describe('Course', () => {
});
});
it('displays weekly goal celebration modal', async () => {
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('displays weekly goal celebration modal', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { weeklyGoal: true } });
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
const { courseware, models } = testStore.getState();
@@ -136,18 +156,6 @@ describe('Course', () => {
});
});
it('displays notification trigger and toggles active class on click', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
waitFor(() => {
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
});
});
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
@@ -360,28 +368,27 @@ describe('Course', () => {
});
});
it('displays chat when screen is wide enough (browser)', async () => {
it('displays learner tools when screen is wide enough (browser)', async () => {
const courseMetadata = Factory.build('courseMetadata', {
learning_assistant_enabled: true,
enrollment: { mode: 'verified' },
});
const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware } = testStore.getState();
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId);
waitFor(() => expect(chat).toBeInTheDocument());
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
await waitFor(() => expect(learnerTools).toBeInTheDocument());
});
it('does not display chat when screen is too narrow (mobile)', async () => {
it('does not display learner tools when screen is too narrow (mobile)', async () => {
global.innerWidth = breakpoints.extraSmall.minWidth;
const courseMetadata = Factory.build('courseMetadata', {
learning_assistant_enabled: true,
enrollment: { mode: 'verified' },
});
const testStore = await initializeTestStore({ courseMetadata }, false);
@@ -393,7 +400,7 @@ describe('Course', () => {
sequenceId,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId);
await expect(chat).not.toBeInTheDocument();
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
await expect(learnerTools).not.toBeInTheDocument();
});
});

View File

@@ -1,134 +0,0 @@
import React from 'react';
import { screen, render } from '@testing-library/react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { BrowserRouter } from 'react-router-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useModel, useModels } from '../../generic/model-store';
import CourseBreadcrumbs from './CourseBreadcrumbs';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/analytics');
// Remove When Fully rolled out>>>
jest.mock('../../generic/model-store');
jest.mock('@edx/frontend-platform/auth');
getConfig.mockImplementation(() => ({ ENABLE_JUMPNAV: 'true' }));
getAuthenticatedUser.mockImplementation(() => ({ administrator: true }));
// ^^^^Remove When Fully rolled out
jest.mock('react-redux', () => ({
connect: (mapStateToProps, mapDispatchToProps) => (ReactComponent) => ({
mapStateToProps,
mapDispatchToProps,
ReactComponent,
}),
Provider: ({ children }) => children,
useSelector: () => 'loaded',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Link: jest.fn().mockImplementation(({ to, children }) => (
<a href={to}>{children}</a>
)),
}));
useModels.mockImplementation((name) => {
if (name === 'sections') {
return [
{
courseId: 'course-v1:edX+DemoX+Demo_Course',
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
sequenceIds: ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction'],
title: 'Introduction',
},
{
courseId: 'course-v1:edX+DemoX+Demo_Course',
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
sequenceIds: ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'],
title: 'Example Week 1: Getting Started',
},
];
}
return [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
title: 'Lesson 1 - Getting Started',
unitIds: [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
],
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
title: 'Homework - Question Styles',
unitIds: [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@54bb9b142c6c4c22afc62bcb628f0e68',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0c92347a5c00',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_1fef54c2b23b',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2889db1677a549abb15eb4d886f95d1c',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e8a5cc2aed424838853defab7be45e42',
],
},
];
});
useModel.mockImplementation(() => ({
sectionIds: ['block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations'],
}));
describe('CourseBreadcrumbs', () => {
jest.spyOn(React, 'useMemo').mockImplementation(() => [
[
{
default: false,
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b',
label: 'Introduction',
url: 'http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction',
},
{
default: true,
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
label: 'Example Week 1: Getting Started',
url: 'http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course/block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
},
],
[
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations', label: "Lesson 2 - Let's Get Interactive!", default: true, url: 'http://localhost:2000/course/course-v1:edX+DemoX+D…e@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0',
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e', label: 'Homework - Essays', default: false, url: 'http://localhost:2000/course/course-v1:edX+DemoX+D…e@vertical+block@fb79dcbad35b466a8c6364f8ffee9050',
},
],
]);
render(
<IntlProvider>
<BrowserRouter>
<CourseBreadcrumbs
courseId="course-v1:edX+DemoX+Demo_Course"
sectionId="block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations"
sequenceId="block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
isStaff
/>
</BrowserRouter>,
</IntlProvider>,
);
it('renders course breadcrumbs as expected', async () => {
expect(screen.queryAllByRole('link')).toHaveLength(1);
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
useToggle,
ModalPopup,
Menu,
Button,
} from '@openedx/paragon';
import { Link, useLocation } from 'react-router-dom';
import JumpNavMenuItem from '../JumpNavMenuItem';
interface Props {
content: {
default: boolean,
id: string,
label: string,
sequences: {
id: string,
}[],
} [];
withSeparator: boolean | false,
separator: string | '';
courseId: string;
sequenceId: string | '';
unitId: string | '';
isStaff: boolean | false;
}
const BreadcrumbItem: React.FC<Props> = ({
content,
withSeparator,
separator,
courseId,
sequenceId,
unitId,
isStaff,
}) => {
const defaultContent = content.filter(
(destination: { default: boolean }) => destination.default,
)[0] || { id: courseId, label: '', sequences: [] };
const showRegularLink = getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff;
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const { pathname } = useLocation();
const isPreview = pathname.startsWith('/preview');
const baseUrl = defaultContent.sequences.length
? `/course/${courseId}/${defaultContent.sequences[0].id}`
: `/course/${courseId}/${defaultContent.id}`;
const link = isPreview ? `/preview${baseUrl}` : baseUrl;
return (
<>
{withSeparator && separator && (
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>{separator}</li>
)}
<li
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-testid="breadcrumb-item"
>
{showRegularLink ? (
<Link
className="text-primary-500"
to={link}
>
{defaultContent.label}
</Link>
) : (
<>
{
// @ts-ignore
<Button className="text-primary-500 m-0 p-0" variant="link" onClick={open} ref={setTarget}>
{defaultContent.label}
</Button>
}
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
<Menu>
{content.map((item) => (
<JumpNavMenuItem
key={item.label}
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
onClick={close}
/>
))}
</Menu>
</ModalPopup>
</>
)}
</li>
</>
);
};
export default BreadcrumbItem;

View File

@@ -1,107 +1,13 @@
import React, { useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { useToggle, ModalPopup, Menu } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { useModel, useModels } from '../../generic/model-store';
import JumpNavMenuItem from './JumpNavMenuItem';
const CourseBreadcrumb = ({
content,
withSeparator,
courseId,
sequenceId,
unitId,
isStaff,
}) => {
const defaultContent = content.filter(
(destination) => destination.default,
)[0] || { id: courseId, label: '', sequences: [] };
const showRegularLink = getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff;
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
return (
<>
{withSeparator && (
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
)}
<li
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-testid="breadcrumb-item"
>
{showRegularLink ? (
<Link
className="text-primary-500"
to={
defaultContent.sequences.length
? `/course/${courseId}/${defaultContent.sequences[0].id}`
: `/course/${courseId}/${defaultContent.id}`
}
>
{defaultContent.label}
</Link>
) : (
<>
{
// eslint-disable-next-line
<a className="text-primary-500" onClick={open} ref={setTarget}>
{defaultContent.label}
</a>
}
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
<Menu>
{content.map((item) => (
<JumpNavMenuItem
key={item.label}
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
onClick={close}
/>
))}
</Menu>
</ModalPopup>
</>
)}
</li>
</>
);
};
CourseBreadcrumb.propTypes = {
content: PropTypes.arrayOf(
PropTypes.shape({
default: PropTypes.bool,
id: PropTypes.string,
label: PropTypes.string,
}),
).isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
withSeparator: PropTypes.bool,
courseId: PropTypes.string,
isStaff: PropTypes.bool,
};
CourseBreadcrumb.defaultProps = {
withSeparator: false,
sequenceId: null,
unitId: null,
courseId: null,
isStaff: null,
};
import { useModel, useModels } from '../../../generic/model-store';
import BreadcrumbItem from './BreadcrumbItem';
const CourseBreadcrumbs = ({
courseId,
@@ -117,7 +23,7 @@ const CourseBreadcrumbs = ({
);
const allSequencesInSections = Object.fromEntries(
useModels('sections', course.sectionIds).map((section) => [
useModels('sections', course.sectionIds)?.map((section) => [
section.id,
{
default: section.id === sectionId,
@@ -171,7 +77,7 @@ const CourseBreadcrumbs = ({
</Link>
</li>
{links.map((content, i) => (
<CourseBreadcrumb
<BreadcrumbItem
// eslint-disable-next-line react/no-array-index-key
key={i}
courseId={courseId}
@@ -179,6 +85,7 @@ const CourseBreadcrumbs = ({
content={content}
unitId={unitId}
withSeparator
separator="/"
isStaff={isStaff}
/>
))}

View File

@@ -0,0 +1,101 @@
import { Factory } from 'rosie';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp, initializeTestStore } from '@src/setupTest';
import CourseBreadcrumbs from './CourseBreadcrumbs';
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
isStaff: true,
};
const courseMetadata = Factory.build('courseMetadata', { courseId: props.courseId, sectionIds: [props.sectionId] });
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', id: props.sequenceId, title: 'Subsection' },
{ courseId: props.courseId },
)];
const sectionBlocks = [Factory.build(
'block',
{
type: 'chapter',
id: props.sectionId,
title: 'Section',
children: [{ id: props.sequenceId }],
},
{ courseId: props.courseId },
)];
initializeMockApp();
describe('CourseBreadcrumbs', () => {
let store = {};
const initTestStore = async () => {
store = await initializeTestStore({ courseMetadata, sectionBlocks, sequenceBlocks });
};
function renderWithProvider(pathname = '/course') {
const { container } = render(
<AppProvider store={store} wrapWithRouter={false}>
<IntlProvider locale="en">
<MemoryRouter initialEntries={[{ pathname }]}>
<CourseBreadcrumbs {...props} />
</MemoryRouter>
</IntlProvider>
</AppProvider>,
);
return container;
}
describe('in live view', () => {
it('renders course breadcrumbs as expected', async () => {
await initTestStore();
renderWithProvider();
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
});
it('section link does not include /preview/', async () => {
await initTestStore();
renderWithProvider();
const sectionBreadcrumb = screen.getByText(sectionBlocks[0].block_id);
const sectionLink = sectionBreadcrumb.closest('a').href;
expect(sectionLink.includes('/preview/')).toBeFalsy();
});
});
describe('in live view', () => {
it('renders course breadcrumbs as expected', async () => {
await initTestStore();
renderWithProvider('/preview/courses');
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
});
it('section link does includes /preview/', async () => {
await initTestStore();
renderWithProvider('/preview/courses');
const sectionBreadcrumb = screen.getByText(sectionBlocks[0].block_id);
const sectionLink = sectionBreadcrumb.closest('a').href;
expect(sectionLink.includes('/preview/')).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,3 @@
import CourseBreadcrumbs from './CourseBreadcrumbs';
export default CourseBreadcrumbs;

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
breakpoints,
@@ -18,8 +18,9 @@ import { recordFirstSectionCelebration } from './utils';
import { useModel } from '../../../generic/model-store';
const CelebrationModal = ({
courseId, intl, isOpen, onClose, ...rest
courseId, isOpen, onClose, ...rest
}) => {
const intl = useIntl();
const { org, celebrations } = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
const wideScreen = useWindowSize().width >= breakpoints.small.minWidth;
@@ -66,9 +67,8 @@ const CelebrationModal = ({
CelebrationModal.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(CelebrationModal);
export default CelebrationModal;

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Button, Icon, StandardModal,
} from '@openedx/paragon';
@@ -12,8 +12,9 @@ import { recordWeeklyGoalCelebration } from './utils';
import { useModel } from '../../../generic/model-store';
const WeeklyGoalCelebrationModal = ({
courseId, daysPerWeek, intl, isOpen, onClose, ...rest
courseId, daysPerWeek, isOpen, onClose, ...rest
}) => {
const intl = useIntl();
const { org } = useModel('courseHomeMeta', courseId);
useEffect(() => {
@@ -77,9 +78,8 @@ const WeeklyGoalCelebrationModal = ({
WeeklyGoalCelebrationModal.propTypes = {
courseId: PropTypes.string.isRequired,
daysPerWeek: PropTypes.number.isRequired,
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(WeeklyGoalCelebrationModal);
export default WeeklyGoalCelebrationModal;

View File

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

View File

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

View File

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

View File

@@ -1,403 +1,389 @@
import React, { Component } from 'react';
import React, { useState } from 'react';
import { Collapsible } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCalculator, faQuestionCircle, faTimesCircle, faEquals,
} from '@fortawesome/free-solid-svg-icons';
import messages from './messages';
class Calculator extends Component {
constructor(props) {
super(props);
this.state = {
equation: '',
result: '',
};
this.handleSubmit = this.handleSubmit.bind(this);
}
const Calculator = () => {
const intl = useIntl();
const [equation, setEquation] = useState('');
const [result, setResult] = useState('');
async handleSubmit(event) {
const handleSubmit = async (event) => {
event.preventDefault();
event.stopPropagation();
const urlEncoded = new URLSearchParams();
urlEncoded.append('equation', this.state.equation);
urlEncoded.append('equation', equation);
const response = await getAuthenticatedHttpClient().get(
`${getConfig().LMS_BASE_URL}/calculate?${urlEncoded.toString()}`,
);
this.setState(() => ({ result: response.data.result }));
}
setResult(response.data.result);
};
changeEquation(value) {
this.setState(() => ({ equation: value }));
}
const changeEquation = (value) => {
setEquation(value);
};
render() {
return (
<Collapsible.Advanced className="calculator">
<div className="text-right">
<Collapsible.Trigger tag="a" className="trigger btn">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faCalculator} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
{this.props.intl.formatMessage(messages['calculator.button.label'])}
</Collapsible.Trigger>
</div>
<Collapsible.Body className="calculator-content pt-4">
<form onSubmit={this.handleSubmit} className="container-xl form-inline flex-nowrap">
<input
type="text"
placeholder={this.props.intl.formatMessage(messages['calculator.input.field.label'])}
aria-label={this.props.intl.formatMessage(messages['calculator.input.field.label'])}
className="form-control w-100"
onChange={(event) => this.changeEquation(event.target.value)}
/>
<button
className="btn btn-primary mx-3"
aria-label={this.props.intl.formatMessage(messages['calculator.submit.button.label'])}
type="submit"
>
<FontAwesomeIcon icon={faEquals} aria-hidden="true" />
</button>
<input
type="text"
tabIndex="-1"
readOnly
aria-live="polite"
placeholder={this.props.intl.formatMessage(messages['calculator.result.field.placeholder'])}
aria-label={this.props.intl.formatMessage(messages['calculator.result.field.label'])}
className="form-control w-50"
value={this.state.result}
/>
</form>
return (
<Collapsible.Advanced className="calculator">
<div className="text-right">
<Collapsible.Trigger tag="a" className="trigger btn">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faCalculator} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
{intl.formatMessage(messages['calculator.button.label'])}
</Collapsible.Trigger>
</div>
<Collapsible.Body className="calculator-content pt-4">
<form onSubmit={handleSubmit} className="container-xl form-inline flex-nowrap">
<input
type="text"
placeholder={intl.formatMessage(messages['calculator.input.field.label'])}
aria-label={intl.formatMessage(messages['calculator.input.field.label'])}
className="form-control w-100"
onChange={(event) => changeEquation(event.target.value)}
/>
<button
className="btn btn-primary mx-3"
aria-label={intl.formatMessage(messages['calculator.submit.button.label'])}
type="submit"
>
<FontAwesomeIcon icon={faEquals} aria-hidden="true" />
</button>
<input
type="text"
tabIndex="-1"
readOnly
aria-live="polite"
placeholder={intl.formatMessage(messages['calculator.result.field.placeholder'])}
aria-label={intl.formatMessage(messages['calculator.result.field.label'])}
className="form-control w-50"
value={result}
/>
</form>
<Collapsible.Advanced>
<div className="container-xl">
<Collapsible.Trigger className="btn btn-link btn-sm px-0 d-inline-flex align-items-center">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faQuestionCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<FormattedMessage
id="calculator.instructions.button.label"
defaultMessage="Calculator Instructions"
/>
</Collapsible.Trigger>
</div>
<Collapsible.Body className="container-xl pt-3" style={{ maxHeight: '50vh', overflow: 'auto' }}>
<Collapsible.Advanced>
<div className="container-xl">
<Collapsible.Trigger className="btn btn-link btn-sm px-0 d-inline-flex align-items-center">
<Collapsible.Visible whenOpen>
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<Collapsible.Visible whenClosed>
<FontAwesomeIcon icon={faQuestionCircle} aria-hidden="true" className="mr-2" />
</Collapsible.Visible>
<FormattedMessage
tagName="h6"
id="calculator.instructions"
defaultMessage="For detailed information, see the {expressions_link}."
description="Text that precedes the link which redirects to help page calculator"
values={{
expressions_link: (
<a href={getConfig().SUPPORT_URL_CALCULATOR_MATH}>
<FormattedMessage
id="calculator.instructions.support.title"
defaultMessage="Help Center"
description="Anchor text for link which redirects to help page calculator"
/>
</a>
),
}}
id="calculator.instructions.button.label"
defaultMessage="Calculator Instructions"
/>
<p>
<strong>
<FormattedMessage
id="calculator.instructions.useful.tips"
defaultMessage="Useful tips:"
description="Headline for the (list of tips) about using the calculator"
/>
</strong>
</p>
<ul>
<li className="hint-item" id="hint-paren">
<FormattedMessage
id="calculator.hint1"
defaultMessage="Use parentheses () to make expressions clear. You can use parentheses inside other parentheses."
description="The text indicate that the calculator supports parentheses"
/>
</li>
<li className="hint-item" id="hint-spaces">
<FormattedMessage
id="calculator.hint2"
defaultMessage="Do not use spaces in expressions."
description="It indicate that using a space might cause un expected behavior"
/>
</li>
<li className="hint-item" id="hint-howto-constants">
<FormattedMessage
id="calculator.hint3"
defaultMessage="For constants, indicate multiplication explicitly (example: 5*c)."
description="It indicate the style of math notation"
/>
</li>
<li className="hint-item" id="hint-howto-maffixes">
<FormattedMessage
id="calculator.hint4"
defaultMessage="For affixes, type the number and affix without a space (example: 5c)."
/>
</li>
<li className="hint-item" id="hint-howto-functions">
<FormattedMessage
id="calculator.hint5"
defaultMessage="For functions, type the name of the function, then the expression in parentheses."
description="It indicate how to use a math function, e.g. exp(4)."
/>
</li>
</ul>
<table className="table small">
<thead>
<tr>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.to.use.heading"
defaultMessage="To Use"
description="Column header which indicate calculator functionality"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.type.heading"
defaultMessage="Type"
description="Column header which indicate the supported type(s) of a the calculator functionality"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.examples.heading"
defaultMessage="Examples"
description="Column header which list examples of calculator functionality"
/>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.numbers"
defaultMessage="Numbers"
description="A calculator functionality"
/>
</th>
<td>
<ul className="list-unstyled m-0">
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type1"
defaultMessage="Integers"
description="Type of numbers that is supported the calculator"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type2"
defaultMessage="Fractions"
description="Type of numbers that is supported by the calculator"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type3"
defaultMessage="Decimals"
description="Type of numbers that is supported by the calculator"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>2520</li>
<li>2/3</li>
<li>3.14, .98</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.operators"
defaultMessage="Operators"
description="A calculator functionality"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>
{' + - * / '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type1"
defaultMessage="(add, subtract, multiply, divide)"
description="Type of opprators that are supported by the calculator"
/>
</li>
<li>
{'^ '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type2"
defaultMessage="(raise to a power)"
description="It indicate that symbol (^) is being used to raise power, e.g. 2^2 = 4"
/>
</li>
<li>
{'|| '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type3"
defaultMessage="(parallel resistors)"
description="It indicate that the sympol (||) is being used to calculate (parallel resistor), it is a concept in electrical/electronic engineering"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>x+(2*y)/x-1</li>
<li>x^(n+1)</li>
<li>v_IN+v_OUT</li>
<li>1||2</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.constants"
defaultMessage="Constants"
description="It indicate that the calculator support constants, e.g. the speed of light"
/>
</th>
<td dir="auto">e, pi</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20*e</li>
<li>418*pi</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes"
defaultMessage="Affixes"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes.type"
defaultMessage="Percent sign (%)"
/>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20%</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.basic.functions"
defaultMessage="Basic functions"
description="It indicate that calculator supports mathematical function"
/>
</th>
<td dir="auto">abs, exp, fact, factorial, ln, log2, log10, sqrt</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>abs(x+y)</li>
<li>sqrt(x^2-y)</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.trig.functions"
defaultMessage="Trigonometric functions"
description="Type of mathematical function that is supported by the calculator"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin, cos, tan, sec, csc, cot</li>
<li>arcsin, sinh, arcsinh</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin(4x+y)</li>
<li>arccsch(4x+y)</li>
</ul>
</td>
<td dir="auto" />
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation"
defaultMessage="Scientific notation"
description="It indicate that calculator supports scientific notation"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type1"
defaultMessage="{exponentSyntax} and the exponent"
description="Type of scientific notation that is supported by the calculator"
values={{
exponentSyntax: '10^',
}}
/>
</td>
<td dir="auto">10^-9</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type2"
defaultMessage="{notationSyntax} notation"
description="It indicate that calculator supports (e) to be used in notation"
values={{
notationSyntax: 'e',
}}
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type3"
defaultMessage="{notationSyntax} and the exponent"
description="An example for using (e) in notation"
values={{
notationSyntax: '1e',
}}
/>
</td>
<td dir="auto">1e-9</td>
</tr>
</tbody>
</table>
</Collapsible.Body>
</Collapsible.Advanced>
</Collapsible.Body>
</Collapsible.Advanced>
);
}
}
Calculator.propTypes = {
intl: intlShape.isRequired,
</Collapsible.Trigger>
</div>
<Collapsible.Body className="container-xl pt-3" style={{ maxHeight: '50vh', overflow: 'auto' }}>
<FormattedMessage
tagName="h6"
id="calculator.instructions"
defaultMessage="For detailed information, see the {expressions_link}."
description="Text that precedes the link which redirects to help page calculator"
values={{
expressions_link: (
<a href={getConfig().SUPPORT_URL_CALCULATOR_MATH}>
<FormattedMessage
id="calculator.instructions.support.title"
defaultMessage="Help Center"
description="Anchor text for link which redirects to help page calculator"
/>
</a>
),
}}
/>
<p>
<strong>
<FormattedMessage
id="calculator.instructions.useful.tips"
defaultMessage="Useful tips:"
description="Headline for the (list of tips) about using the calculator"
/>
</strong>
</p>
<ul>
<li className="hint-item" id="hint-paren">
<FormattedMessage
id="calculator.hint1"
defaultMessage="Use parentheses () to make expressions clear. You can use parentheses inside other parentheses."
description="The text indicate that the calculator supports parentheses"
/>
</li>
<li className="hint-item" id="hint-spaces">
<FormattedMessage
id="calculator.hint2"
defaultMessage="Do not use spaces in expressions."
description="It indicate that using a space might cause un expected behavior"
/>
</li>
<li className="hint-item" id="hint-howto-constants">
<FormattedMessage
id="calculator.hint3"
defaultMessage="For constants, indicate multiplication explicitly (example: 5*c)."
description="It indicate the style of math notation"
/>
</li>
<li className="hint-item" id="hint-howto-maffixes">
<FormattedMessage
id="calculator.hint4"
defaultMessage="For affixes, type the number and affix without a space (example: 5c)."
/>
</li>
<li className="hint-item" id="hint-howto-functions">
<FormattedMessage
id="calculator.hint5"
defaultMessage="For functions, type the name of the function, then the expression in parentheses."
description="It indicate how to use a math function, e.g. exp(4)."
/>
</li>
</ul>
<table className="pgn__data-table small">
<thead>
<tr>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.to.use.heading"
defaultMessage="To Use"
description="Column header which indicate calculator functionality"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.type.heading"
defaultMessage="Type"
description="Column header which indicate the supported type(s) of a the calculator functionality"
/>
</th>
<th scope="col">
<FormattedMessage
id="calculator.instruction.table.examples.heading"
defaultMessage="Examples"
description="Column header which list examples of calculator functionality"
/>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.numbers"
defaultMessage="Numbers"
description="A calculator functionality"
/>
</th>
<td>
<ul className="list-unstyled m-0">
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type1"
defaultMessage="Integers"
description="Type of numbers that is supported the calculator"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type2"
defaultMessage="Fractions"
description="Type of numbers that is supported by the calculator"
/>
</li>
<li>
<FormattedMessage
id="calculator.instruction.table.to.use.numbers.type3"
defaultMessage="Decimals"
description="Type of numbers that is supported by the calculator"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>2520</li>
<li>2/3</li>
<li>3.14, .98</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.operators"
defaultMessage="Operators"
description="A calculator functionality"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>
{' + - * / '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type1"
defaultMessage="(add, subtract, multiply, divide)"
description="Type of opprators that are supported by the calculator"
/>
</li>
<li>
{'^ '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type2"
defaultMessage="(raise to a power)"
description="It indicate that symbol (^) is being used to raise power, e.g. 2^2 = 4"
/>
</li>
<li>
{'|| '}
<FormattedMessage
id="calculator.instruction.table.to.use.operators.type3"
defaultMessage="(parallel resistors)"
description="It indicate that the sympol (||) is being used to calculate (parallel resistor), it is a concept in electrical/electronic engineering"
/>
</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>x+(2*y)/x-1</li>
<li>x^(n+1)</li>
<li>v_IN+v_OUT</li>
<li>1||2</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.constants"
defaultMessage="Constants"
description="It indicate that the calculator support constants, e.g. the speed of light"
/>
</th>
<td dir="auto">e, pi</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20*e</li>
<li>418*pi</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes"
defaultMessage="Affixes"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.affixes.type"
defaultMessage="Percent sign (%)"
/>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>20%</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.basic.functions"
defaultMessage="Basic functions"
description="It indicate that calculator supports mathematical function"
/>
</th>
<td dir="auto">abs, exp, fact, factorial, ln, log2, log10, sqrt</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>abs(x+y)</li>
<li>sqrt(x^2-y)</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.trig.functions"
defaultMessage="Trigonometric functions"
description="Type of mathematical function that is supported by the calculator"
/>
</th>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin, cos, tan, sec, csc, cot</li>
<li>arcsin, sinh, arcsinh</li>
</ul>
</td>
<td dir="auto">
<ul className="list-unstyled m-0">
<li>sin(4x+y)</li>
<li>arccsch(4x+y)</li>
</ul>
</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation"
defaultMessage="Scientific notation"
description="It indicate that calculator supports scientific notation"
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type1"
defaultMessage="{exponentSyntax} and the exponent"
description="Type of scientific notation that is supported by the calculator"
values={{
exponentSyntax: '10^',
}}
/>
</td>
<td dir="auto">10^-9</td>
</tr>
<tr>
<th scope="row">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type2"
defaultMessage="{notationSyntax} notation"
description="It indicate that calculator supports (e) to be used in notation"
values={{
notationSyntax: 'e',
}}
/>
</th>
<td dir="auto">
<FormattedMessage
id="calculator.instruction.table.to.use.scientific.notation.type3"
defaultMessage="{notationSyntax} and the exponent"
description="An example for using (e) in notation"
values={{
notationSyntax: '1e',
}}
/>
</td>
<td dir="auto">1e-9</td>
</tr>
</tbody>
</table>
</Collapsible.Body>
</Collapsible.Advanced>
</Collapsible.Body>
</Collapsible.Advanced>
);
};
export default injectIntl(Calculator);
export default Calculator;

View File

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

View File

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

View File

@@ -1,11 +1,9 @@
import React, { Component } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } 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 { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import messages from './messages';
@@ -15,47 +13,41 @@ function toggleNotes() {
iframe.contentWindow.postMessage('tools.toggleNotes', getConfig().LMS_BASE_URL);
}
class NotesVisibility extends Component {
constructor(props) {
super(props);
this.state = {
visible: props.course.notes.visible,
};
this.visibilityUrl = `${getConfig().LMS_BASE_URL}/courses/${props.course.id}/edxnotes/visibility/`;
}
const NotesVisibility = ({ course }) => {
const intl = useIntl();
const [visible, setVisible] = useState(course.notes.visible);
const visibilityUrl = `${getConfig().LMS_BASE_URL}/courses/${course.id}/edxnotes/visibility/`;
handleClick = () => {
const data = { visibility: !this.state.visible };
const handleClick = () => {
const data = { visibility: !visible };
getAuthenticatedHttpClient().put(
this.visibilityUrl,
visibilityUrl,
data,
).then(() => {
this.setState((state) => ({ visible: !state.visible }));
setVisible(!visible);
toggleNotes();
});
};
render() {
const message = this.state.visible ? 'notes.button.hide' : 'notes.button.show';
return (
<button
className={`trigger btn ${this.state.visible ? 'text-secondary' : 'text-success'} mx-2 `}
role="switch"
type="button"
onClick={this.handleClick}
onKeyDown={this.handleClick}
tabIndex="-1"
aria-checked={this.state.visible ? 'true' : 'false'}
>
<FontAwesomeIcon icon={faPencilAlt} aria-hidden="true" className="mr-2" />
{this.props.intl.formatMessage(messages[message])}
</button>
);
}
}
const message = visible ? 'notes.button.hide' : 'notes.button.show';
return (
<button
className={`trigger btn ${visible ? 'text-secondary' : 'text-success'} mx-2 `}
role="switch"
type="button"
onClick={handleClick}
onKeyDown={handleClick}
tabIndex="-1"
aria-checked={visible ? 'true' : 'false'}
>
<FontAwesomeIcon icon={faPencilAlt} aria-hidden="true" className="mr-2" />
{intl.formatMessage(messages[message])}
</button>
);
};
NotesVisibility.propTypes = {
intl: intlShape.isRequired,
course: PropTypes.shape({
id: PropTypes.string.isRequired,
notes: PropTypes.shape({
@@ -64,4 +56,4 @@ NotesVisibility.propTypes = {
}).isRequired,
};
export default injectIntl(NotesVisibility);
export default NotesVisibility;

View File

@@ -4,9 +4,7 @@ import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { faSearch } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -16,7 +14,8 @@ import { useModel } from '../../../generic/model-store';
import messages from './messages';
import { logClick } from './utils';
const CatalogSuggestion = ({ intl, variant }) => {
const CatalogSuggestion = ({ variant }) => {
const intl = useIntl();
const { courseId } = useSelector(state => state.courseware);
const { org } = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser();
@@ -48,8 +47,7 @@ const CatalogSuggestion = ({ intl, variant }) => {
};
CatalogSuggestion.propTypes = {
intl: intlShape.isRequired,
variant: PropTypes.string.isRequired,
};
export default injectIntl(CatalogSuggestion);
export default CatalogSuggestion;

View File

@@ -2,9 +2,7 @@ import React, { useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLinkedinIn } from '@fortawesome/free-brands-svg-icons';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -27,16 +25,17 @@ import messages from './messages';
import { useModel } from '../../../generic/model-store';
import { requestCert } from '../../../course-home/data/thunks';
import ProgramCompletion from './ProgramCompletion';
import DashboardFootnote from './DashboardFootnote';
import UpgradeFootnote from './UpgradeFootnote';
import SocialIcons from '../../social-share/SocialIcons';
import { logClick, logVisit } from './utils';
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
import CourseRecommendationsSlot from '../../../plugin-slots/CourseRecommendationsSlot';
import DashboardFootnote from './DashboardFootnote';
import { CourseRecommendationsSlot } from '../../../plugin-slots/CourseExitPluginSlots';
const LINKEDIN_BLUE = '#2867B2';
const CourseCelebration = ({ intl }) => {
const CourseCelebration = () => {
const intl = useIntl();
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
const { courseId } = useSelector(state => state.courseware);
const dispatch = useDispatch();
@@ -364,8 +363,4 @@ const CourseCelebration = ({ intl }) => {
);
};
CourseCelebration.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseCelebration);
export default CourseCelebration;

View File

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

View File

@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useSelector } from 'react-redux';
import { Alert, Button } from '@openedx/paragon';
@@ -14,7 +14,8 @@ import DashboardFootnote from './DashboardFootnote';
import messages from './messages';
import { logClick, logVisit } from './utils';
const CourseInProgress = ({ intl }) => {
const CourseInProgress = () => {
const intl = useIntl();
const { courseId } = useSelector(state => state.courseware);
const {
org,
@@ -60,8 +61,4 @@ const CourseInProgress = ({ intl }) => {
);
};
CourseInProgress.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseInProgress);
export default CourseInProgress;

View File

@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useSelector } from 'react-redux';
import { Alert, Button } from '@openedx/paragon';
@@ -14,7 +14,8 @@ import DashboardFootnote from './DashboardFootnote';
import messages from './messages';
import { logClick, logVisit } from './utils';
const CourseNonPassing = ({ intl }) => {
const CourseNonPassing = () => {
const intl = useIntl();
const { courseId } = useSelector(state => state.courseware);
const {
org,
@@ -60,8 +61,4 @@ const CourseNonPassing = ({ intl }) => {
);
};
CourseNonPassing.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseNonPassing);
export default CourseNonPassing;

View File

@@ -4,7 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
FormattedMessage, injectIntl, intlShape, defineMessages,
FormattedMessage, useIntl, defineMessages,
} from '@edx/frontend-platform/i18n';
import { useSelector, useDispatch } from 'react-redux';
import {
@@ -64,8 +64,8 @@ const CourseCard = ({
marketingUrl,
onClick,
},
intl,
}) => {
const intl = useIntl();
const formatList = (items, style) => (
items.join(intl.formatMessage(
messages.listJoin,
@@ -127,12 +127,12 @@ CourseCard.propTypes = {
})),
onClick: PropTypes.func,
}).isRequired,
intl: intlShape.isRequired,
};
const IntlCard = injectIntl(CourseCard);
const IntlCard = CourseCard;
const CourseRecommendations = ({ intl, variant }) => {
const CourseRecommendations = ({ variant }) => {
const intl = useIntl();
const { courseId, recommendationsStatus } = useSelector(state => ({ ...state.recommendations, ...state.courseware }));
const { recommendations } = useModel('coursewareMeta', courseId);
const { org, number } = useModel('courseHomeMeta', courseId);
@@ -205,8 +205,7 @@ const CourseRecommendations = ({ intl, variant }) => {
};
CourseRecommendations.propTypes = {
intl: intlShape.isRequired,
variant: PropTypes.string.isRequired,
};
export default injectIntl(CourseRecommendations);
export default CourseRecommendations;

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Button, Hyperlink } from '@openedx/paragon';
import certImage from '../../../generic/assets/edX_certificate.png';
import messages from './messages';
@@ -20,12 +20,12 @@ import messages from './messages';
const programTypes = ['microbachelors', 'micromasters', 'professional-certificate', 'xseries'];
const ProgramCompletion = ({
intl,
progress,
title,
type,
url,
}) => {
const intl = useIntl();
if (!programTypes.includes(type) || progress.notStarted !== 0 || progress.inProgress !== 0) {
return null;
}
@@ -98,7 +98,6 @@ const ProgramCompletion = ({
};
ProgramCompletion.propTypes = {
intl: intlShape.isRequired,
progress: PropTypes.shape({
completed: PropTypes.number.isRequired,
inProgress: PropTypes.number.isRequired,
@@ -109,4 +108,4 @@ ProgramCompletion.propTypes = {
url: PropTypes.string.isRequired,
};
export default injectIntl(ProgramCompletion);
export default ProgramCompletion;

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