Compare commits

...

49 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
Nathan Sprenkle
dbe917f692 Merge branch 'openedx:master' into master 2025-06-05 13:30:54 -04:00
Jorg Are
69f1ca5a99 Merge branch 'openedx:master' into master 2025-06-04 16:03:48 +01:00
Jorg Are
d4de38a8e7 Merge branch 'openedx:master' into master 2025-05-22 11:27:34 +01:00
Nathan Sprenkle
6736e6cd26 Merge branch 'openedx:master' into master 2025-05-19 11:46:12 -04:00
Nathan Sprenkle
b6ab78c244 Merge branch 'openedx:master' into master 2025-05-12 10:46:13 -04: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
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
82 changed files with 3163 additions and 1047 deletions

3
.env
View File

@@ -12,6 +12,7 @@ CREDIT_HELP_LINK_URL=''
CSRF_TOKEN_API_PATH='' CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL='' DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL='' DISCUSSIONS_MFE_BASE_URL=''
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='' ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
@@ -50,3 +51,5 @@ TWITTER_URL=''
USER_INFO_COOKIE_NAME='' USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY='' OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -12,6 +12,7 @@ CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-co
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130' ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
@@ -52,3 +53,5 @@ CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy' PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY='' OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -12,6 +12,7 @@ CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-co
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130' ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true' ENABLE_JUMPNAV='true'
ENABLE_NOTICES='' ENABLE_NOTICES=''
@@ -50,3 +51,4 @@ USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy' PRIVACY_POLICY_URL='http://localhost:18000/privacy'
SHOW_UNGRADED_ASSIGNMENT_PROGRESS='' SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:Enterprise' ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:Enterprise'
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'

View File

@@ -40,9 +40,10 @@ pull_translations:
translations/frontend-component-header/src/i18n/messages:frontend-component-header \ translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-lib-special-exams/src/i18n/messages:frontend-lib-special-exams \ 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. # This target is used by Travis.

1196
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@edx/browserslist-config": "1.5.0", "@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.6.0", "@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0", "@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-lib-learning-assistant": "^2.20.0", "@edx/frontend-lib-learning-assistant": "^2.24.0",
"@edx/frontend-lib-special-exams": "^4.0.0", "@edx/frontend-lib-special-exams": "^4.0.0",
"@edx/frontend-platform": "^8.3.1", "@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0", "@edx/openedx-atlas": "^0.7.0",
@@ -24,7 +24,7 @@
"@fortawesome/react-fontawesome": "^0.1.4", "@fortawesome/react-fontawesome": "^0.1.4",
"@openedx/frontend-build": "^14.5.0", "@openedx/frontend-build": "^14.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0", "@openedx/paragon": "^23.4.5",
"@popperjs/core": "2.11.8", "@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.9.7", "@reduxjs/toolkit": "1.9.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",
@@ -1940,6 +1940,158 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@bundled-es-modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w==",
"license": "ISC",
"dependencies": {
"deepmerge": "^4.3.1"
}
},
"node_modules/@bundled-es-modules/glob": {
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/glob/-/glob-10.4.2.tgz",
"integrity": "sha512-740y5ofkzydsFao5EXJrGilcIL6EFEw/cmPf2uhTw9J6G1YOhiIFjNFCHdpgEiiH5VlU3G0SARSjlFlimRRSMA==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"buffer": "^6.0.3",
"events": "^3.3.0",
"glob": "^10.4.2",
"patch-package": "^8.0.0",
"path": "^0.12.7",
"stream": "^0.0.3",
"string_decoder": "^1.3.0",
"url": "^0.11.3"
}
},
"node_modules/@bundled-es-modules/glob/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@bundled-es-modules/glob/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@bundled-es-modules/glob/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@bundled-es-modules/memfs": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/memfs/-/memfs-4.17.0.tgz",
"integrity": "sha512-ykdrkEmQr9BV804yd37ikXfNnvxrwYfY9Z2/EtMHFEFadEjsQXJ1zL9bVZrKNLDtm91UdUOEHso6Aweg93K6xQ==",
"license": "Apache-2.0",
"dependencies": {
"assert": "^2.1.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"memfs": "^4.17.0",
"path": "^0.12.7",
"stream": "^0.0.3",
"util": "^0.12.5"
}
},
"node_modules/@bundled-es-modules/memfs/node_modules/memfs": {
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.1.tgz",
"integrity": "sha512-thuTRd7F4m4dReCIy7vv4eNYnU6XI/tHMLSMMHLiortw/Y0QxqKtinG523U2aerzwYWGi606oBP4oMPy4+edag==",
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/json-pack": "^1.0.3",
"@jsonjoy.com/util": "^1.3.0",
"tree-dump": "^1.0.1",
"tslib": "^2.0.0"
},
"engines": {
"node": ">= 4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/streamich"
}
},
"node_modules/@bundled-es-modules/postcss-calc-ast-parser": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.6.tgz",
"integrity": "sha512-y65TM5zF+uaxo9OeekJ3rxwTINlQvrkbZLogYvQYVoLtxm4xEiHfZ7e/MyiWbStYyWZVZkVqsaVU6F4SUK5XUA==",
"license": "ISC",
"dependencies": {
"postcss-calc-ast-parser": "^0.1.4"
}
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.0.3",
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"license": "Apache-2.0"
},
"node_modules/@cospired/i18n-iso-languages": { "node_modules/@cospired/i18n-iso-languages": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-4.2.0.tgz",
@@ -2261,9 +2413,9 @@
} }
}, },
"node_modules/@edx/frontend-lib-learning-assistant": { "node_modules/@edx/frontend-lib-learning-assistant": {
"version": "2.21.0", "version": "2.24.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-2.21.0.tgz", "resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-2.24.0.tgz",
"integrity": "sha512-CUzPCQaBgXi6E1kvY0nyBSVFu8RUGpwKH4V0p8ZuysyHyRHpA+339b+gEi9FvVBMP/X4IxZHsZhi7nphlr43Iw==", "integrity": "sha512-+RwmKbYxsJ6Ct9scBX3jnxSUuoiW5ed1vbCz9PQiQ8fobuiMM3fokLynIreB5ZVYWvrjSa5OaMwBq1bUXsprZw==",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
@@ -3154,6 +3306,102 @@
"deprecated": "Use @eslint/object-schema instead", "deprecated": "Use @eslint/object-schema instead",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@istanbuljs/load-nyc-config": { "node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -3556,6 +3804,60 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@jsonjoy.com/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/streamich"
},
"peerDependencies": {
"tslib": "2"
}
},
"node_modules/@jsonjoy.com/json-pack": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz",
"integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==",
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/base64": "^1.1.1",
"@jsonjoy.com/util": "^1.1.2",
"hyperdyperid": "^1.2.0",
"thingies": "^1.20.0"
},
"engines": {
"node": ">=10.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/streamich"
},
"peerDependencies": {
"tslib": "2"
}
},
"node_modules/@jsonjoy.com/util": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz",
"integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/streamich"
},
"peerDependencies": {
"tslib": "2"
}
},
"node_modules/@leichtgewicht/ip-codec": { "node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
@@ -4137,9 +4439,9 @@
} }
}, },
"node_modules/@openedx/paragon": { "node_modules/@openedx/paragon": {
"version": "22.17.0", "version": "23.4.5",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.17.0.tgz", "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.4.5.tgz",
"integrity": "sha512-MzOLQ0myaOErwumPJwxVZXTw7zJKrARtu4YMSaISF5Sz6pE1/dYz9qfRcqaraYRcJGNdbPRzOG0v3iqbZo1uHQ==", "integrity": "sha512-baBTZDO6hdCjI+Jj3oSQaz5GkFTR2TEs94pPMOls7bc/Fsv4zQIN8xDPo4NzAVo/2+3eSuEzUz3xzBeb+94rtw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"example", "example",
@@ -4149,20 +4451,32 @@
"dependent-usage-analyzer" "dependent-usage-analyzer"
], ],
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@popperjs/core": "^2.11.4", "@popperjs/core": "^2.11.4",
"@tokens-studio/sd-transforms": "^1.2.4",
"axios": "^0.27.2",
"bootstrap": "^4.6.2", "bootstrap": "^4.6.2",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"child_process": "^1.0.2", "child_process": "^1.0.2",
"chroma-js": "^2.4.2",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"cli-progress": "^3.12.0",
"commander": "^9.4.1",
"email-prop-type": "^3.0.0", "email-prop-type": "^3.0.0",
"file-selector": "^0.6.0", "file-selector": "^0.6.0",
"font-awesome": "^4.7.0",
"glob": "^8.0.3", "glob": "^8.0.3",
"inquirer": "^8.2.5", "inquirer": "^8.2.5",
"js-toml": "^1.0.0",
"lodash.uniqby": "^4.7.0", "lodash.uniqby": "^4.7.0",
"log-update": "^4.0.0",
"mailto-link": "^2.0.0", "mailto-link": "^2.0.0",
"minimist": "^1.2.8",
"ora": "^5.4.1",
"postcss": "^8.4.21",
"postcss-combine-duplicated-selectors": "^10.0.3",
"postcss-custom-media": "^9.1.2",
"postcss-import": "^15.1.0",
"postcss-map": "^0.11.0",
"postcss-minify": "^1.1.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-bootstrap": "^1.6.5", "react-bootstrap": "^1.6.5",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
@@ -4175,6 +4489,8 @@
"react-responsive": "^8.2.0", "react-responsive": "^8.2.0",
"react-table": "^7.7.0", "react-table": "^7.7.0",
"react-transition-group": "^4.4.2", "react-transition-group": "^4.4.2",
"sass": "^1.58.3",
"style-dictionary": "^4.3.2",
"tabbable": "^5.3.3", "tabbable": "^5.3.3",
"uncontrollable": "^7.2.1", "uncontrollable": "^7.2.1",
"uuid": "^9.0.0" "uuid": "^9.0.0"
@@ -4188,6 +4504,16 @@
"react-intl": "^5.25.1 || ^6.4.0" "react-intl": "^5.25.1 || ^6.4.0"
} }
}, },
"node_modules/@openedx/paragon/node_modules/axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/@openedx/paragon/node_modules/brace-expansion": { "node_modules/@openedx/paragon/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -4197,6 +4523,15 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/@openedx/paragon/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/@openedx/paragon/node_modules/glob": { "node_modules/@openedx/paragon/node_modules/glob": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@@ -4229,6 +4564,34 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@openedx/paragon/node_modules/postcss-custom-media": {
"version": "9.1.5",
"resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-9.1.5.tgz",
"integrity": "sha512-GStyWMz7Qbo/Gtw1xVspzVSX8eipgNg4lpsO3CAeY4/A1mzok+RV6MCv3fg62trWijh/lYEj6vps4o8JcBBpDA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/cascade-layer-name-parser": "^1.0.2",
"@csstools/css-parser-algorithms": "^2.2.0",
"@csstools/css-tokenizer": "^2.1.1",
"@csstools/media-query-list-parser": "^2.1.1"
},
"engines": {
"node": "^14 || ^16 || >=18"
},
"peerDependencies": {
"postcss": "^8.4"
}
},
"node_modules/@optimizely/js-sdk-logging": { "node_modules/@optimizely/js-sdk-logging": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@optimizely/js-sdk-logging/-/js-sdk-logging-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-logging/-/js-sdk-logging-0.3.1.tgz",
@@ -4731,6 +5094,16 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": { "node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz",
@@ -5268,6 +5641,32 @@
"@testing-library/dom": ">=7.21.4" "@testing-library/dom": ">=7.21.4"
} }
}, },
"node_modules/@tokens-studio/sd-transforms": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@tokens-studio/sd-transforms/-/sd-transforms-1.3.0.tgz",
"integrity": "sha512-zVbiYjTGWpSuwzZwiuvcWf79CQEcTMKSxrOaQJ0zHXFxEmrpETWeIRxv2IO8rtMos/cS8mvnDwPngoHQOMs1SA==",
"license": "MIT",
"dependencies": {
"@bundled-es-modules/deepmerge": "^4.3.1",
"@bundled-es-modules/postcss-calc-ast-parser": "^0.1.6",
"@tokens-studio/types": "^0.5.1",
"colorjs.io": "^0.5.2",
"expr-eval-fork": "^2.0.2",
"is-mergeable-object": "^1.1.1"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"style-dictionary": "^4.3.0 || ^5.0.0-rc.0"
}
},
"node_modules/@tokens-studio/types": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.5.2.tgz",
"integrity": "sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==",
"license": "MIT"
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -6504,6 +6903,23 @@
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"license": "BSD-2-Clause"
},
"node_modules/@zip.js/zip.js": {
"version": "2.7.60",
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.60.tgz",
"integrity": "sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA==",
"license": "BSD-3-Clause",
"engines": {
"bun": ">=0.7.0",
"deno": ">=1.0.0",
"node": ">=16.5.0"
}
},
"node_modules/abab": { "node_modules/abab": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -6955,6 +7371,19 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.2",
"is-nan": "^1.3.2",
"object-is": "^1.1.5",
"object.assign": "^4.1.4",
"util": "^0.12.5"
}
},
"node_modules/assert-ok": { "node_modules/assert-ok": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-ok/-/assert-ok-1.0.0.tgz", "resolved": "https://registry.npmjs.org/assert-ok/-/assert-ok-1.0.0.tgz",
@@ -6967,6 +7396,15 @@
"integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/async": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -7934,6 +8372,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"license": "MIT"
},
"node_modules/char-regex": { "node_modules/char-regex": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
@@ -8077,6 +8521,20 @@
"boolbase": "~1.0.0" "boolbase": "~1.0.0"
} }
}, },
"node_modules/chevrotain": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
"@chevrotain/regexp-to-ast": "11.0.3",
"@chevrotain/types": "11.0.3",
"@chevrotain/utils": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/child_process": { "node_modules/child_process": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz",
@@ -8113,6 +8571,12 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/chroma-js": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz",
"integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==",
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/chrome-trace-event": { "node_modules/chrome-trace-event": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@@ -8204,6 +8668,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
"integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
"license": "MIT",
"dependencies": {
"string-width": "^4.2.3"
},
"engines": {
"node": ">=4"
}
},
"node_modules/cli-spinners": { "node_modules/cli-spinners": {
"version": "2.9.2", "version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
@@ -8348,6 +8824,12 @@
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -9656,6 +10138,12 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -11059,6 +11547,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/expr-eval-fork": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-2.0.2.tgz",
"integrity": "sha512-NaAnObPVwHEYrODd7Jzp3zzT9pgTAlUUL4MZiZu9XAYPDpx89cPsfyEImFb2XY0vQNbrqg2CG7CLiI+Rs3seaQ==",
"license": "MIT"
},
"node_modules/express": { "node_modules/express": {
"version": "4.21.2", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -11479,6 +11973,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat": { "node_modules/flat": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
@@ -11556,15 +12059,6 @@
} }
} }
}, },
"node_modules/font-awesome": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
"integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==",
"license": "(OFL-1.1 AND MIT)",
"engines": {
"node": ">=0.10.3"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -11580,6 +12074,34 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/foreground-child/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fork-ts-checker-webpack-plugin": { "node_modules/fork-ts-checker-webpack-plugin": {
"version": "6.5.3", "version": "6.5.3",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz",
@@ -12664,6 +13186,15 @@
"node": ">=10.17.0" "node": ">=10.17.0"
} }
}, },
"node_modules/hyperdyperid": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz",
"integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==",
"license": "MIT",
"engines": {
"node": ">=10.18"
}
},
"node_modules/hyphenate-style-name": { "node_modules/hyphenate-style-name": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
@@ -13039,6 +13570,22 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -13369,6 +13916,28 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-mergeable-object": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-mergeable-object/-/is-mergeable-object-1.1.1.tgz",
"integrity": "sha512-CPduJfuGg8h8vW74WOxHtHmtQutyQBzR+3MjQ6iDHIYdbOnm1YC7jv43SqCoU8OPGTJD4nibmiryA4kmogbGrA==",
"license": "MIT"
},
"node_modules/is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -13818,6 +14387,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jake": { "node_modules/jake": {
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
@@ -14792,6 +15376,16 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-toml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/js-toml/-/js-toml-1.0.1.tgz",
"integrity": "sha512-rHd/IolpFm2V5BmHCEY8CckHs8NDsYZZ64H5RNgA6Opsr9vX4QyTiQPplgtqg7b3ztqYShZC38nl6CUg7QuhXg==",
"license": "MIT",
"dependencies": {
"chevrotain": "^11.0.3",
"xregexp": "^5.1.1"
}
},
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.14.1", "version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
@@ -15037,6 +15631,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -15178,6 +15781,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.assignin": { "node_modules/lodash.assignin": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
@@ -15332,6 +15941,24 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/log-update": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
"integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
"license": "MIT",
"dependencies": {
"ansi-escapes": "^4.3.0",
"cli-cursor": "^3.1.0",
"slice-ansi": "^4.0.0",
"wrap-ansi": "^6.2.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -16191,6 +16818,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp-classic": { "node_modules/mkdirp-classic": {
"version": "0.5.3", "version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -16492,6 +17128,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": { "node_modules/object-keys": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -16829,6 +17481,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/param-case": { "node_modules/param-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -16900,6 +17558,95 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/patch-package": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz",
"integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==",
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^9.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"rimraf": "^2.6.3",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.0.33",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/patch-package/node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/path": {
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
"license": "MIT",
"dependencies": {
"process": "^0.11.1",
"util": "^0.10.3"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -16939,6 +17686,28 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -16954,6 +17723,27 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-unified": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/path-unified/-/path-unified-0.2.0.tgz",
"integrity": "sha512-MNKqvrKbbbb5p7XHXV6ZAsf/1f/yJQa13S/fcX0uua8ew58Tgc6jXV+16JyAbnR/clgCH+euKDxrF2STxMHdrg==",
"license": "MIT"
},
"node_modules/path/node_modules/inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
"license": "ISC"
},
"node_modules/path/node_modules/util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
"license": "MIT",
"dependencies": {
"inherits": "2.0.3"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -17357,6 +18147,24 @@
"postcss": "^8.2.2" "postcss": "^8.2.2"
} }
}, },
"node_modules/postcss-calc-ast-parser": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.4.tgz",
"integrity": "sha512-CebpbHc96zgFjGgdQ6BqBy6XIUgRx1xXWCAAk6oke02RZ5nxwo9KQejTg8y7uYEeI9kv8jKQPYjoe6REsY23vw==",
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^3.3.1"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/postcss-calc-ast-parser/node_modules/postcss-value-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
"integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==",
"license": "MIT"
},
"node_modules/postcss-colormin": { "node_modules/postcss-colormin": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz",
@@ -17375,6 +18183,21 @@
"postcss": "^8.4.31" "postcss": "^8.4.31"
} }
}, },
"node_modules/postcss-combine-duplicated-selectors": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/postcss-combine-duplicated-selectors/-/postcss-combine-duplicated-selectors-10.0.3.tgz",
"integrity": "sha512-IP0BmwFloCskv7DV7xqvzDXqMHpwdczJa6ZvIW8abgHdcIHs9mCJX2ltFhu3EwA51ozp13DByng30+Ke+eIExA==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "^6.0.4"
},
"engines": {
"node": "^10.0.0 || ^12.0.0 || >=14.0.0"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/postcss-convert-values": { "node_modules/postcss-convert-values": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz",
@@ -17467,6 +18290,23 @@
"postcss": "^8.4.31" "postcss": "^8.4.31"
} }
}, },
"node_modules/postcss-import": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
"resolve": "^1.1.7"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"postcss": "^8.0.0"
}
},
"node_modules/postcss-loader": { "node_modules/postcss-loader": {
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz",
@@ -17554,6 +18394,52 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/postcss-map": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/postcss-map/-/postcss-map-0.11.0.tgz",
"integrity": "sha512-cgHYZrH9aAMds90upYUPhYz8xnAcRD45SwuNns/nQHONIrPQDhpwk3JLsAQGOndQxnRVXfB6nB+3WqSMy8fqlA==",
"license": "Unlicense",
"dependencies": {
"js-yaml": "^3.12.0",
"postcss": "^7.0.2",
"reduce-function-call": "^1.0.1"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/postcss-map/node_modules/picocolors": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
"integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
"license": "ISC"
},
"node_modules/postcss-map/node_modules/postcss": {
"version": "7.0.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
"integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
"license": "MIT",
"dependencies": {
"picocolors": "^0.2.1",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
}
},
"node_modules/postcss-map/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postcss-merge-longhand": { "node_modules/postcss-merge-longhand": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz", "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz",
@@ -17588,6 +18474,19 @@
"postcss": "^8.4.31" "postcss": "^8.4.31"
} }
}, },
"node_modules/postcss-minify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/postcss-minify/-/postcss-minify-1.1.0.tgz",
"integrity": "sha512-9D64ueIW0DL2FdLajQTlXrnTN8Ox9NjuXqigKMmB819RhdClNPYx5Zp3i5x0ghjjy3vGrLBBYEYvJjY/1eMNbw==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "^6.0",
"postcss-value-parser": "^4.1"
},
"peerDependencies": {
"postcss": "^8.0"
}
},
"node_modules/postcss-minify-font-values": { "node_modules/postcss-minify-font-values": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz",
@@ -18054,6 +18953,21 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-error": { "node_modules/pretty-error": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
@@ -18100,7 +19014,6 @@
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6.0" "node": ">= 0.6.0"
@@ -19165,6 +20078,24 @@
"react-dom": ">=16.6.0" "react-dom": ">=16.6.0"
} }
}, },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
}
},
"node_modules/read-cache/node_modules/pify": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/read-pkg": { "node_modules/read-pkg": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -19292,6 +20223,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/reduce-function-call": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz",
"integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/redux": { "node_modules/redux": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
@@ -20508,6 +21448,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/slice-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
"integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/snake-case": { "node_modules/snake-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
@@ -20766,6 +21723,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/stream": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/stream/-/stream-0.0.3.tgz",
"integrity": "sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A==",
"license": "MIT",
"dependencies": {
"component-emitter": "^2.0.0"
}
},
"node_modules/stream/node_modules/component-emitter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz",
"integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/streamx": { "node_modules/streamx": {
"version": "2.22.0", "version": "2.22.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
@@ -20824,6 +21802,27 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string-width/node_modules/emoji-regex": { "node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -20925,6 +21924,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": { "node_modules/strip-bom": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
@@ -20967,6 +21979,55 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/style-dictionary": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-4.4.0.tgz",
"integrity": "sha512-+xU0IA1StzqAqFs/QtXkK+XJa7wpS4X5H+JQccRKsRCElgeLGocFU1U/UMvMUylKFw6vwGV+Y/a2wb2pm5rFFQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@bundled-es-modules/deepmerge": "^4.3.1",
"@bundled-es-modules/glob": "^10.4.2",
"@bundled-es-modules/memfs": "^4.9.4",
"@zip.js/zip.js": "^2.7.44",
"chalk": "^5.3.0",
"change-case": "^5.3.0",
"commander": "^12.1.0",
"is-plain-obj": "^4.1.0",
"json5": "^2.2.2",
"patch-package": "^8.0.0",
"path-unified": "^0.2.0",
"prettier": "^3.3.3",
"tinycolor2": "^1.6.0"
},
"bin": {
"style-dictionary": "bin/style-dictionary.js"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/style-dictionary/node_modules/chalk": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/style-dictionary/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/style-loader": { "node_modules/style-loader": {
"version": "3.3.4", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz",
@@ -21345,6 +22406,18 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/thingies": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz",
"integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==",
"license": "Unlicense",
"engines": {
"node": ">=10.18"
},
"peerDependencies": {
"tslib": "^2"
}
},
"node_modules/thread-stream": { "node_modules/thread-stream": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz",
@@ -21379,6 +22452,12 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
@@ -21505,6 +22584,22 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/tree-dump": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz",
"integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/streamich"
},
"peerDependencies": {
"tslib": "2"
}
},
"node_modules/trim-lines": { "node_modules/trim-lines": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -22197,6 +23292,19 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
"integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
"license": "MIT",
"dependencies": {
"punycode": "^1.4.1",
"qs": "^6.12.3"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/url-loader": { "node_modules/url-loader": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
@@ -22252,6 +23360,12 @@
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
} }
}, },
"node_modules/url/node_modules/punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT"
},
"node_modules/use-callback-ref": { "node_modules/use-callback-ref": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
@@ -22304,6 +23418,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -23063,6 +24190,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -23118,6 +24263,15 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/xregexp": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-5.1.2.tgz",
"integrity": "sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==",
"license": "MIT",
"dependencies": {
"@babel/runtime-corejs3": "^7.26.9"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -37,7 +37,7 @@
"@edx/browserslist-config": "1.5.0", "@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.6.0", "@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0", "@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-lib-learning-assistant": "^2.20.0", "@edx/frontend-lib-learning-assistant": "^2.24.0",
"@edx/frontend-lib-special-exams": "^4.0.0", "@edx/frontend-lib-special-exams": "^4.0.0",
"@edx/frontend-platform": "^8.3.1", "@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0", "@edx/openedx-atlas": "^0.7.0",
@@ -48,7 +48,7 @@
"@fortawesome/react-fontawesome": "^0.1.4", "@fortawesome/react-fontawesome": "^0.1.4",
"@openedx/frontend-build": "^14.5.0", "@openedx/frontend-build": "^14.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0", "@openedx/paragon": "^23.4.5",
"@popperjs/core": "2.11.8", "@popperjs/core": "2.11.8",
"@reduxjs/toolkit": "1.9.7", "@reduxjs/toolkit": "1.9.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",

View File

@@ -68,7 +68,7 @@ exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<Preferences Unsubscribe /> <Preferences Unsubscribe />
</PageWrap> </PageWrap>
} }
path="/preferences-unsubscribe/:userToken/:updatePatch" path="/preferences-unsubscribe/:userToken/:updatePatch?"
/> />
<Route <Route
element={ element={

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
"courseHome": { "courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded", "courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading", "proctoringPanelStatus": "loading",
"showSearch": false, "showSearch": false,
"targetUserId": undefined, "targetUserId": undefined,
@@ -397,6 +398,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"courseHome": { "courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded", "courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading", "proctoringPanelStatus": "loading",
"showSearch": false, "showSearch": false,
"targetUserId": undefined, "targetUserId": undefined,
@@ -528,6 +530,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
"hideFromTOC": undefined, "hideFromTOC": undefined,
"icon": null, "icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", "id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"isPreview": false,
"navigationDisabled": undefined, "navigationDisabled": undefined,
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true, "showLink": true,
@@ -669,6 +672,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"courseHome": { "courseHome": {
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded", "courseStatus": "loaded",
"examsData": null,
"proctoringPanelStatus": "loading", "proctoringPanelStatus": "loading",
"showSearch": false, "showSearch": false,
"targetUserId": undefined, "targetUserId": undefined,
@@ -761,6 +765,19 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"progress": { "progress": {
"course-v1:edX+DemoX+Demo_Course": { "course-v1:edX+DemoX+Demo_Course": {
"accessExpiration": null, "accessExpiration": null,
"assignmentTypeGradeSummary": [
{
"averageGrade": 1,
"hasHiddenContribution": "none",
"lastGradePublishDate": null,
"numDroppable": 1,
"numTotal": 2,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"certificateData": {}, "certificateData": {},
"completionSummary": { "completionSummary": {
"completeCount": 1, "completeCount": 1,
@@ -776,17 +793,17 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
"creditCourseRequirements": null, "creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z", "end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit", "enrollmentMode": "audit",
"finalGrades": 0.5,
"gradesFeatureIsFullyLocked": false, "gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false, "gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": { "gradingPolicy": {
"assignmentPolicies": [ "assignmentPolicies": [
{ {
"averageGrade": "1.0000",
"numDroppable": 1, "numDroppable": 1,
"numTotal": 2,
"shortLabel": "HW", "shortLabel": "HW",
"type": "Homework", "type": "Homework",
"weight": 1, "weight": 1,
"weightedGrade": 1,
}, },
], ],
"gradeRange": { "gradeRange": {

View File

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

View File

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

View File

@@ -297,4 +297,178 @@ describe('Data layer integration tests', () => {
expect(enabled).toBe(false); expect(enabled).toBe(false);
}); });
}); });
describe('Test fetchExamAttemptsData', () => {
const sequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@abcde',
];
beforeEach(() => {
// Mock individual exam endpoints with different responses
sequenceIds.forEach((sequenceId, index) => {
// Handle both LMS and EXAMS service URL patterns
const lmsExamUrl = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceId)}.*`);
const examsServiceUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
let attemptStatus = 'ready_to_start';
if (index === 0) {
attemptStatus = 'created';
} else if (index === 1) {
attemptStatus = 'submitted';
}
const mockExamData = {
exam: {
id: index + 1,
course_id: courseId,
content_id: sequenceId,
exam_name: `Test Exam ${index + 1}`,
attempt_status: attemptStatus,
time_remaining_seconds: 3600,
},
};
// Mock both URL patterns
axiosMock.onGet(lmsExamUrl).reply(200, mockExamData);
axiosMock.onGet(examsServiceUrl).reply(200, mockExamData);
});
});
it('should fetch exam data for all sequence IDs and dispatch setExamsData', async () => {
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify the examsData was set in the store
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData).toEqual([
{
id: 1,
courseId,
contentId: sequenceIds[0],
examName: 'Test Exam 1',
attemptStatus: 'created',
timeRemainingSeconds: 3600,
},
{
id: 2,
courseId,
contentId: sequenceIds[1],
examName: 'Test Exam 2',
attemptStatus: 'submitted',
timeRemainingSeconds: 3600,
},
{
id: 3,
courseId,
contentId: sequenceIds[2],
examName: 'Test Exam 3',
attemptStatus: 'ready_to_start',
timeRemainingSeconds: 3600,
},
]);
// Verify all API calls were made
expect(axiosMock.history.get).toHaveLength(3);
});
it('should handle 404 responses and include empty objects in results', async () => {
// Override one endpoint to return 404 for both URL patterns
const examUrl404LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl404Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
axiosMock.onGet(examUrl404LMS).reply(404);
axiosMock.onGet(examUrl404Exams).reply(404);
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify the examsData includes empty object for 404 response
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData[1]).toEqual({});
});
it('should handle API errors and log them while continuing with other requests', async () => {
// Override one endpoint to return 500 error for both URL patterns
const examUrl500LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl500Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
axiosMock.onGet(examUrl500LMS).reply(500, { error: 'Server Error' });
axiosMock.onGet(examUrl500Exams).reply(500, { error: 'Server Error' });
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
// Verify error was logged for the failed request
expect(loggingService.logError).toHaveBeenCalled();
// Verify the examsData still includes results for successful requests
expect(state.courseHome.examsData).toHaveLength(3);
// First item should be the error result (just empty object for API errors)
expect(state.courseHome.examsData[0]).toEqual({});
});
it('should handle empty sequence IDs array', async () => {
await executeThunk(thunks.fetchExamAttemptsData(courseId, []), store.dispatch);
const state = store.getState();
expect(state.courseHome.examsData).toEqual([]);
expect(axiosMock.history.get).toHaveLength(0);
});
it('should handle mixed success and error responses', async () => {
// Setup mixed responses
const examUrl1LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl1Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
const examUrl2LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl2Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
const examUrl3LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[2])}.*`);
const examUrl3Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[2])}.*`);
axiosMock.onGet(examUrl1LMS).reply(200, {
exam: {
id: 1,
exam_name: 'Success Exam',
course_id: courseId,
content_id: sequenceIds[0],
attempt_status: 'created',
time_remaining_seconds: 3600,
},
});
axiosMock.onGet(examUrl1Exams).reply(200, {
exam: {
id: 1,
exam_name: 'Success Exam',
course_id: courseId,
content_id: sequenceIds[0],
attempt_status: 'created',
time_remaining_seconds: 3600,
},
});
axiosMock.onGet(examUrl2LMS).reply(404);
axiosMock.onGet(examUrl2Exams).reply(404);
axiosMock.onGet(examUrl3LMS).reply(500, { error: 'Server Error' });
axiosMock.onGet(examUrl3Exams).reply(500, { error: 'Server Error' });
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
const state = store.getState();
expect(state.courseHome.examsData).toHaveLength(3);
expect(state.courseHome.examsData[0]).toMatchObject({
id: 1,
examName: 'Success Exam',
courseId,
contentId: sequenceIds[0],
});
expect(state.courseHome.examsData[1]).toEqual({});
expect(state.courseHome.examsData[2]).toEqual({});
// Verify error was logged for the 500 error (may be called more than once due to multiple URL patterns)
expect(loggingService.logError).toHaveBeenCalled();
});
});
}); });

View File

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

View File

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

View File

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

View File

@@ -171,6 +171,7 @@ const OutlineTab = () => {
</div> </div>
{rootCourseId && ( {rootCourseId && (
<div className="col col-12 col-md-4"> <div className="col col-12 col-md-4">
<CourseOutlineTabNotificationsSlot courseId={courseId} />
<ProctoringInfoPanel /> <ProctoringInfoPanel />
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as { /** 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 */ } disabled to avoid components bouncing around too much as screen is rendered */ }
@@ -181,7 +182,6 @@ const OutlineTab = () => {
/> />
)} )}
<CourseTools /> <CourseTools />
<CourseOutlineTabNotificationsSlot courseId={courseId} />
<CourseDates /> <CourseDates />
<CourseHandouts /> <CourseHandouts />
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,17 +13,25 @@ import Course from './Course';
import setupDiscussionSidebar from './test-utils'; import setupDiscussionSidebar from './test-utils';
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({ jest.mock('@edx/frontend-lib-special-exams', () => {
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'), const actual = jest.requireActual('@edx/frontend-lib-special-exams');
checkExamEntry: () => jest.fn(), return {
})); ...actual,
const mockChatTestId = 'fake-chat'; __esModule: true,
// Mock the default export (SequenceExamWrapper) to just render children
// eslint-disable-next-line react/prop-types
default: ({ children }) => <div data-testid="sequence-exam-wrapper">{children}</div>,
};
});
const mockLearnerToolsTestId = 'fake-learner-tools';
jest.mock( jest.mock(
'./chat/Chat', '../../plugin-slots/LearnerToolsSlot',
// eslint-disable-next-line react/prop-types () => ({
() => function ({ courseId }) { // eslint-disable-next-line react/prop-types
return <div className="fake-chat" data-testid={mockChatTestId}>Chat contents {courseId} </div>; LearnerToolsSlot({ courseId }) {
}, return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
},
}),
); );
const recordFirstSectionCelebration = jest.fn(); const recordFirstSectionCelebration = jest.fn();
@@ -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', { const courseMetadata = Factory.build('courseMetadata', {
learning_assistant_enabled: true,
enrollment: { mode: 'verified' }, enrollment: { mode: 'verified' },
}); });
const testStore = await initializeTestStore({ courseMetadata }, false); const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware } = testStore.getState(); const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware; const { courseId, sequenceId } = courseware;
const testData = { const testData = {
...mockData, ...mockData,
courseId, courseId,
sequenceId, sequenceId,
unitId: Object.values(models.units)[0].id,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId); const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
waitFor(() => expect(chat).toBeInTheDocument()); await waitFor(() => expect(learnerTools).toBeInTheDocument());
}); });
it('does not display chat when screen is too narrow (mobile)', async () => { it('does not display learner tools when screen is too narrow (mobile)', async () => {
global.innerWidth = breakpoints.extraSmall.minWidth; global.innerWidth = breakpoints.extraSmall.minWidth;
const courseMetadata = Factory.build('courseMetadata', { const courseMetadata = Factory.build('courseMetadata', {
learning_assistant_enabled: true,
enrollment: { mode: 'verified' }, enrollment: { mode: 'verified' },
}); });
const testStore = await initializeTestStore({ courseMetadata }, false); const testStore = await initializeTestStore({ courseMetadata }, false);
@@ -393,7 +400,7 @@ describe('Course', () => {
sequenceId, sequenceId,
}; };
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true }); render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const chat = screen.queryByTestId(mockChatTestId); const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
await expect(chat).not.toBeInTheDocument(); await expect(learnerTools).not.toBeInTheDocument();
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,8 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import CelebrationMobile from './assets/celebration_456x328.gif'; import CelebrationMobile from './assets/celebration_456x328.gif';
import CelebrationDesktop from './assets/celebration_750x540.gif'; import CelebrationDesktop from './assets/celebration_750x540.gif';
import certificate from '../../../generic/assets/openedx_certificate.png'; import certificate from '../../../generic/assets/edX_certificate.png';
import certificateLocked from '../../../generic/assets/openedx_locked_certificate.png'; import certificateLocked from '../../../generic/assets/edX_locked_certificate.png';
import { FormattedPricing } from '../../../generic/upgrade-button'; import { FormattedPricing } from '../../../generic/upgrade-button';
import messages from './messages'; import messages from './messages';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,19 @@ describe('<Unit />', () => {
expect(nextButton).toBeVisible(); expect(nextButton).toBeVisible();
}); });
// Test for accessibility compliance: unit title must be an h1 (heading level 1) as the page's primary heading
// for screen reader and accessibility compliance.
// See: https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_components/create_html_component.html#the-visual-editor
// JIRA: https://2u-internal.atlassian.net/browse/AU-2135
it('renders unit title as h1 heading for accessibility', () => {
renderComponent(defaultProps);
const unitTitle = screen.getByRole('heading', { level: 1 });
expect(unitTitle).toBeInTheDocument();
expect(unitTitle.tagName).toBe('H1');
});
}); });
describe('UnitSuspense', () => { describe('UnitSuspense', () => {

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
#course-sidebar { #course-sidebar {
@media (max-width: map-get($grid-breakpoints, "lg")) { @media (--pgn-size-breakpoint-max-width-lg) {
overflow-y: scroll; overflow-y: scroll;
padding: 0 .625rem !important; padding: 0 .625rem !important;
} }

View File

@@ -6,7 +6,7 @@
} }
.outline-sidebar { .outline-sidebar {
@media (min-width: map-get($grid-breakpoints, "xl")) { @media (--pgn-size-breakpoint-min-width-xl) {
left: 0; left: 0;
top: 0; top: 0;
} }
@@ -23,12 +23,12 @@
} }
.outline-sidebar-heading { .outline-sidebar-heading {
font-weight: $font-weight-bold; font-weight: var(--pgn-typography-font-weight-bold);
} }
} }
.course-sidebar-section { .course-sidebar-section {
background: $white; background: var(--pgn-color-white);
border: 1px solid #d7d3d1; border: 1px solid #d7d3d1;
button { button {
@@ -52,7 +52,7 @@
#outline-sidebar-outline { #outline-sidebar-outline {
margin-top: -1px; margin-top: -1px;
@media (min-width: map-get($grid-breakpoints, "xl")) { @media (--pgn-size-breakpoint-min-width-xl) {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -62,14 +62,14 @@
.collapsible-trigger { .collapsible-trigger {
border-radius: 0; border-radius: 0;
padding: map-get($spacers, 3\.5) map-get($spacers, 4) map-get($spacers, 3\.5) map-get($spacers, 5); padding: var(--pgn-spacing-spacer-3-5) var(--pgn-spacing-spacer-4) var(--pgn-spacing-spacer-3-5) var(--pgn-spacing-spacer-5);
@media (max-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-max-width-sm) {
padding-left: map-get($spacers, 4); padding-left: var(--pgn-spacing-spacer-4);
} }
&:hover { &:hover {
background-color: $light-500; background-color: var(--pgn-color-light-500);
} }
.collapsible-icon { .collapsible-icon {
@@ -78,7 +78,7 @@
} }
&:last-child .pgn_collapsible { &:last-child .pgn_collapsible {
@extend .mb-0; margin-bottom: 0px !important;
} }
} }
@@ -86,15 +86,15 @@
padding: 0; padding: 0;
ol li > a { ol li > a {
padding: map-get($spacers, 3\.5) map-get($spacers, 4) map-get($spacers, 3\.5) map-get($spacers, 5\.5); padding: var(--pgn-spacing-spacer-3-5) var(--pgn-spacing-spacer-4) var(--pgn-spacing-spacer-3-5) var(--pgn-spacing-spacer-5-5);
@media (max-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-max-width-sm) {
padding-left: map-get($spacers, 4\.5); padding-left: var(--pgn-spacing-spacer-4-5);
} }
&:hover { &:hover {
text-decoration: none; text-decoration: none;
background-color: $light-500; background-color: var(--pgn-color-light-500);
} }
} }
} }

View File

@@ -1,5 +1,5 @@
.discussions-sidebar-frame { .discussions-sidebar-frame {
@media (max-width: map-get($grid-breakpoints, "xl")) { @media (--pgn-size-breakpoint-max-width-xl) {
max-height: calc(100vh - 65px); max-height: calc(100vh - 65px);
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -8,6 +8,6 @@
} }
.upsell-bullet a { .upsell-bullet a {
color: $primary-500; color: var(--pgn-color-primary-500);
} }

View File

@@ -166,11 +166,13 @@ subscribe(APP_INIT_ERROR, (error) => {
initialize({ initialize({
handlers: { handlers: {
config: () => { config: () => {
/* istanbul ignore next */
mergeConfig({ mergeConfig({
CONTACT_URL: process.env.CONTACT_URL || null, CONTACT_URL: process.env.CONTACT_URL || null,
CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null, CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null,
CREDIT_HELP_LINK_URL: process.env.CREDIT_HELP_LINK_URL || null, CREDIT_HELP_LINK_URL: process.env.CREDIT_HELP_LINK_URL || null,
DISCUSSIONS_MFE_BASE_URL: process.env.DISCUSSIONS_MFE_BASE_URL || null, DISCUSSIONS_MFE_BASE_URL: process.env.DISCUSSIONS_MFE_BASE_URL || null,
DISCOUNT_CODE_INFO_URL: process.env.DISCOUNT_CODE_INFO_URL || null,
ENTERPRISE_LEARNER_PORTAL_HOSTNAME: process.env.ENTERPRISE_LEARNER_PORTAL_HOSTNAME || null, ENTERPRISE_LEARNER_PORTAL_HOSTNAME: process.env.ENTERPRISE_LEARNER_PORTAL_HOSTNAME || null,
ENTERPRISE_LEARNER_PORTAL_URL: process.env.ENTERPRISE_LEARNER_PORTAL_URL || null, ENTERPRISE_LEARNER_PORTAL_URL: process.env.ENTERPRISE_LEARNER_PORTAL_URL || null,
ENABLE_JUMPNAV: process.env.ENABLE_JUMPNAV || null, ENABLE_JUMPNAV: process.env.ENABLE_JUMPNAV || null,
@@ -194,6 +196,7 @@ initialize({
PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL || null, PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL || null,
SHOW_UNGRADED_ASSIGNMENT_PROGRESS: process.env.SHOW_UNGRADED_ASSIGNMENT_PROGRESS || false, SHOW_UNGRADED_ASSIGNMENT_PROGRESS: process.env.SHOW_UNGRADED_ASSIGNMENT_PROGRESS || false,
ENABLE_XPERT_AUDIT: process.env.ENABLE_XPERT_AUDIT || false, ENABLE_XPERT_AUDIT: process.env.ENABLE_XPERT_AUDIT || false,
FEATURE_ENABLE_CHAT_V2_ENDPOINT: process.env.FEATURE_ENABLE_CHAT_V2_ENDPOINT || false,
}, 'LearnerAppConfig'); }, 'LearnerAppConfig');
}, },
}, },

View File

@@ -1,7 +1,4 @@
@import "~@edx/brand/paragon/fonts"; @use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-footer/dist/footer"; @import "~@edx/frontend-component-footer/dist/footer";
@import "~@edx/frontend-component-header/dist/index"; @import "~@edx/frontend-component-header/dist/index";
@@ -51,7 +48,7 @@
.nav-link { .nav-link {
border-bottom: 4px solid transparent; border-bottom: 4px solid transparent;
border-top: 4px solid transparent; border-top: 4px solid transparent;
color: $gray-700; color: var(--pgn-color-gray-700);
// temporary until we can remove .btn class from dropdowns // temporary until we can remove .btn class from dropdowns
border-left: 0; border-left: 0;
@@ -61,9 +58,9 @@
&:hover, &:hover,
&:focus, &:focus,
&.active { &.active {
font-weight: $font-weight-normal; font-weight: var(--pgn-typography-font-weight-normal);
color: $primary-500; color: var(--pgn-color-primary-500);
border-bottom-color: $primary-500; border-bottom-color: var(--pgn-color-primary-500);
} }
} }
} }
@@ -82,7 +79,7 @@
} }
.sequence { .sequence {
@media (min-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-min-width-sm) {
border: solid 1px #eaeaea; border: solid 1px #eaeaea;
border-radius: 4px; border-radius: 4px;
} }
@@ -94,7 +91,7 @@
} }
.notification-btn { .notification-btn {
@media (max-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-max-width-xs) {
height: 3rem; height: 3rem;
} }
} }
@@ -103,15 +100,15 @@
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
@media (max-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-max-width-xs) {
max-width: 100%; max-width: 100%;
} }
@media (min-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-min-width-sm) {
margin: -1px -1px 0; margin: -1px -1px 0;
} }
@media (max-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-max-width-xs) {
width: 100% !important; width: 100% !important;
} }
@@ -127,13 +124,13 @@
height: 3rem; height: 3rem;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: $gray-500; color: var(--pgn-color-gray-500);
white-space: nowrap; white-space: nowrap;
&:hover, &:hover,
&:focus, &:focus,
&.active { &.active {
color: $gray-700; color: var(--pgn-color-gray-700);
} }
&:focus { &:focus {
@@ -148,13 +145,13 @@
left: 0; left: 0;
right: 0; right: 0;
height: 2px; height: 2px;
background: $primary; background: var(--pgn-color-primary-base);
} }
} }
&.complete { &.complete {
background-color: #eef7e5; background-color: #eef7e5;
color: $success; color: var(--pgn-color-success-base);
} }
&:first-child { &:first-child {
@@ -218,12 +215,12 @@
min-width: 0; min-width: 0;
margin: 0 1rem; margin: 0 1rem;
text-overflow: ellipsis; text-overflow: ellipsis;
color: $gray-700; color: var(--pgn-color-gray-700);
} }
&.active { &.active {
.unit-icon { .unit-icon {
color: $primary-500; color: var(--pgn-color-primary-500);
} }
&:after { &:after {
@@ -235,7 +232,7 @@
right: auto; right: auto;
width: 2px; width: 2px;
height: auto; height: auto;
background: $primary; background: var(--pgn-color-primary-base);
} }
} }
} }
@@ -250,18 +247,18 @@
.previous-btn, .previous-btn,
.next-btn { .next-btn {
border: 1px solid $light-400 !important; border: 1px solid var(--pgn-color-light-400) !important;
color: $gray-700; color: var(--pgn-color-gray-700);
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@media (max-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-max-width-sm) {
padding-top: 1rem; padding-top: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
@media (min-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-min-width-sm) {
min-width: fit-content; min-width: fit-content;
padding-left: 2rem; padding-left: 2rem;
padding-right: 2rem; padding-right: 2rem;
@@ -272,7 +269,7 @@
border-left-width: 0; border-left-width: 0;
margin-left: 0; margin-left: 0;
@media (min-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-min-width-sm) {
border-left-width: 1px; border-left-width: 1px;
border-top-left-radius: 4px; border-top-left-radius: 4px;
} }
@@ -282,7 +279,7 @@
border-left-width: 1px; border-left-width: 1px;
border-right-width: 0; border-right-width: 0;
@media (min-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-min-width-sm) {
border-top-right-radius: 4px; border-top-right-radius: 4px;
border-right-width: 1px; border-right-width: 1px;
} }
@@ -296,15 +293,20 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@media (min-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-min-width-sm) {
padding-left: $grid-gutter-width; padding-left: var(--pgn-spacing-grid-gutter-width);
padding-right: $grid-gutter-width; padding-right: var(--pgn-spacing-grid-gutter-width);
} }
@media (min-width: 830px) { @media (min-width: 830px) {
padding-left: 40px; padding-left: 40px;
padding-right: 40px; padding-right: 40px;
} }
// Unit title is styled as an H3
.unit-title {
font-size: var(--pgn-typography-font-size-h3-base);
}
} }
.unit-iframe-wrapper { .unit-iframe-wrapper {
@@ -316,8 +318,8 @@
// here we compensate for the padding of the parent div with "container-xl" // here we compensate for the padding of the parent div with "container-xl"
// class to ensure that the viewport width is the same as the width of the // class to ensure that the viewport width is the same as the width of the
// iframe. // iframe.
margin-left: -$grid-gutter-width * .5; margin-left: calc(var(--pgn-spacing-grid-gutter-width) * -0.5);
margin-right: -$grid-gutter-width * .5; margin-right: calc(var(--pgn-spacing-grid-gutter-width) * -0.5);
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -339,9 +341,9 @@
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
@media (max-width: map-get($grid-breakpoints, "sm")) { @media (--pgn-size-breakpoint-max-width-xs) {
flex-direction: column; flex-direction: column;
gap: $spacer; gap: var(--pgn-spacing-spacer-base);
} }
.previous-button, .previous-button,
@@ -370,19 +372,22 @@
// window (retaining padding around the edge). Bootstrap modals don't have a full-screen // window (retaining padding around the edge). Bootstrap modals don't have a full-screen
// size like this. Because of the hack below around react-focus-on's div, it would be better long-term to pull this into Paragon and perhaps call it "modal-full" or something like that. // size like this. Because of the hack below around react-focus-on's div, it would be better long-term to pull this into Paragon and perhaps call it "modal-full" or something like that.
.modal-lti { .modal-lti {
height: 100%; height: 80vh;
max-width: 100% !important; max-width: 100% !important;
// I don't like this. We need to set a height of 100% on a div created by react-focus-on, a // I don't like this. We need to set a height of 100% on a div created by react-focus-on, a
// package we use in our Modal. That div has no class name or ID, so instead we're uniquely // package we use in our Modal. That div has no class name or ID, so instead we're uniquely
// identifying it by based on a unique attribute it has which its siblings don't share. // identifying it by based on a unique attribute it has which its siblings don't share.
> div[data-focus-lock-disabled="false"] { > div[data-focus-lock-disabled="false"] {
height: 100%; height: 80vh;
} }
// Along with setting the height of modal-content's parent div from react-focus-on, we need to // Along with setting the height of modal-content's parent div from react-focus-on, we need to
// set modal-content's height as well to get the modal to expand to full-screen height. // set modal-content's height as well to get the modal to expand to full-screen height.
.modal-content { .modal-content {
height: 80vh;
}
.pgn__modal-body-content {
height: 100%; height: 100%;
} }
} }
@@ -411,8 +416,8 @@
.icon-hover { .icon-hover {
&:hover { &:hover {
color: $primary-500 !important; color: var(--pgn-color-primary-500) !important;
background-color: $light-300 !important; background-color: var(--pgn-color-light-300) !important;
} }
} }
@@ -435,7 +440,7 @@
height: 56px !important; height: 56px !important;
} }
@include media-breakpoint-down(xs) { @media (--pgn-size-breakpoint-max-width-xs) {
.course-outline-tab .pgn__card { .course-outline-tab .pgn__card {
.pgn__card-header { .pgn__card-header {
display: block; display: block;

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Input } from '@openedx/paragon'; import { Form } from '@openedx/paragon';
import { MasqueradeStatus, Payload } from './data/api'; import { MasqueradeStatus, Payload } from './data/api';
import messages from './messages'; import messages from './messages';
@@ -40,11 +40,10 @@ export const MasqueradeUserNameInput: React.FC<Props> = ({ onSubmit, onError, ..
}, [handleSubmit]); }, [handleSubmit]);
return ( return (
<Input <Form.Control
aria-labelledby="masquerade-search-label" aria-labelledby="masquerade-search-label"
label={intl.formatMessage(messages.userNameLabel)} label={intl.formatMessage(messages.userNameLabel)}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
type="text"
{...otherProps} {...otherProps}
/> />
); );

View File

@@ -0,0 +1,39 @@
# Content iFrame Error Slot
### Slot ID: `org.openedx.frontend.learning.content_iframe_error.v1`
### Parameters: `courseId`
## Description
This slot is used to replace/modify the content iframe error page.
## Example
The following `env.config.jsx` will replace the error page with emojis.
```js
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
'org.openedx.frontend.learning.content_iframe_error.v1': {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_error_page',
type: DIRECT_PLUGIN,
RenderWidget: ({courseId}) => (
<h1>🚨🤖💥</h1>
),
},
},
]
}
},
}
export default config;
```

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { ErrorPage } from '@edx/frontend-platform/react';
interface Props {
courseId: string;
}
export const ContentIFrameErrorSlot : React.FC<Props> = ({ courseId }: Props) => (
<PluginSlot
id="org.openedx.frontend.learning.content_iframe_error.v1"
pluginProps={{ courseId }}
>
<ErrorPage />
</PluginSlot>
);

View File

@@ -0,0 +1,28 @@
# Learner Tools Slot
### Slot ID: `org.openedx.frontend.learning.learner_tools.v1`
### Slot ID Aliases
* `learner_tools_slot`
### Description
This plugin slot provides a location for learner-facing tools and features to be displayed during course content navigation. The slot is rendered via a React portal to `document.body` to ensure proper positioning and stacking context.
### Props:
* `courseId` - The unique identifier for the current course
* `unitId` - The unique identifier for the current unit/vertical being viewed
* `userId` - The authenticated user's ID (automatically retrieved from auth context)
* `isStaff` - Boolean indicating whether the user has staff/instructor privileges
* `enrollmentMode` - The user's enrollment mode (e.g., 'audit', 'verified', 'honor', etc.)
### Usage
Plugins registered to this slot can use the provided context to:
- Display course-specific tools based on courseId and unitId
- Show different features based on user's enrollment mode
- Provide staff-only functionality when isStaff is true
- Query additional data from Redux store or backend APIs as needed
### Notes
- Returns `null` if user is not authenticated
- Plugins should manage their own feature flag checks and requirements
- The slot uses a portal to render to `document.body` for flexible positioning

View File

@@ -0,0 +1,47 @@
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
export const LearnerToolsSlot = ({
enrollmentMode = null,
isStaff,
courseId,
unitId,
}) => {
const authenticatedUser = getAuthenticatedUser();
// Return null if user is not authenticated to avoid destructuring errors
if (!authenticatedUser) {
return null;
}
const { userId } = authenticatedUser;
// Provide minimal, generic context - no feature-specific flags
const pluginContext = {
courseId,
unitId,
userId,
isStaff,
enrollmentMode,
};
// Use generic plugin slot ID (location-based, not feature-specific)
// Plugins will query their own requirements from Redux/config
return createPortal(
<PluginSlot
id="org.openedx.frontend.learning.learner_tools.v1"
idAliases={['learner_tools_slot']}
pluginProps={pluginContext}
/>,
document.body,
);
};
LearnerToolsSlot.propTypes = {
isStaff: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { render } from '@testing-library/react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import * as auth from '@edx/frontend-platform/auth';
import { LearnerToolsSlot } from './index';
jest.mock('@openedx/frontend-plugin-framework', () => ({
PluginSlot: jest.fn(() => <div data-testid="plugin-slot">Plugin Slot</div>),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(),
}));
describe('LearnerToolsSlot', () => {
const defaultProps = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@unit1',
isStaff: false,
enrollmentMode: 'verified',
};
beforeEach(() => {
jest.clearAllMocks();
// Mock document.body for createPortal
document.body.innerHTML = '<div id="root"></div>';
});
it('renders PluginSlot with correct props when user is authenticated', () => {
const mockUser = { userId: 123, username: 'testuser' };
auth.getAuthenticatedUser.mockReturnValue(mockUser);
render(<LearnerToolsSlot {...defaultProps} />);
expect(PluginSlot).toHaveBeenCalledWith(
expect.objectContaining({
id: 'org.openedx.frontend.learning.learner_tools.v1',
idAliases: ['learner_tools_slot'],
pluginProps: {
courseId: defaultProps.courseId,
unitId: defaultProps.unitId,
userId: mockUser.userId,
isStaff: defaultProps.isStaff,
enrollmentMode: defaultProps.enrollmentMode,
},
}),
{},
);
});
it('returns null when user is not authenticated', () => {
auth.getAuthenticatedUser.mockReturnValue(null);
const { container } = render(<LearnerToolsSlot {...defaultProps} />);
expect(container.firstChild).toBeNull();
expect(PluginSlot).not.toHaveBeenCalled();
});
it('uses default null for enrollmentMode when not provided', () => {
const mockUser = { userId: 456, username: 'testuser2' };
auth.getAuthenticatedUser.mockReturnValue(mockUser);
const { enrollmentMode, ...propsWithoutEnrollmentMode } = defaultProps;
render(<LearnerToolsSlot {...propsWithoutEnrollmentMode} />);
expect(PluginSlot).toHaveBeenCalledWith(
expect.objectContaining({
pluginProps: expect.objectContaining({
enrollmentMode: null,
}),
}),
{},
);
});
it('passes isStaff=true correctly', () => {
const mockUser = { userId: 789, username: 'staffuser' };
auth.getAuthenticatedUser.mockReturnValue(mockUser);
render(<LearnerToolsSlot {...defaultProps} isStaff />);
expect(PluginSlot).toHaveBeenCalledWith(
expect.objectContaining({
pluginProps: expect.objectContaining({
isStaff: true,
}),
}),
{},
);
});
it('renders to document.body via portal', () => {
const mockUser = { userId: 999, username: 'portaluser' };
auth.getAuthenticatedUser.mockReturnValue(mockUser);
render(<LearnerToolsSlot {...defaultProps} />);
// The portal should render to document.body
expect(document.body.querySelector('[data-testid="plugin-slot"]')).toBeInTheDocument();
});
});

View File

@@ -11,6 +11,7 @@
* [`org.openedx.frontend.learning.course_outline_tab_notifications.v1`](./CourseOutlineTabNotificationsSlot/) * [`org.openedx.frontend.learning.course_outline_tab_notifications.v1`](./CourseOutlineTabNotificationsSlot/)
* [`org.openedx.frontend.learning.course_recommendations.v1`](./CourseRecommendationsSlot/) * [`org.openedx.frontend.learning.course_recommendations.v1`](./CourseRecommendationsSlot/)
* [`org.openedx.frontend.learning.gated_unit_content_message.v1`](./GatedUnitContentMessageSlot/) * [`org.openedx.frontend.learning.gated_unit_content_message.v1`](./GatedUnitContentMessageSlot/)
* [`org.openedx.frontend.learning.learner_tools.v1`](./LearnerToolsSlot/)
* [`org.openedx.frontend.learning.next_unit_top_nav_trigger.v1`](./NextUnitTopNavTriggerSlot/) * [`org.openedx.frontend.learning.next_unit_top_nav_trigger.v1`](./NextUnitTopNavTriggerSlot/)
* [`org.openedx.frontend.learning.notification_tray.v1`](./NotificationTraySlot/) * [`org.openedx.frontend.learning.notification_tray.v1`](./NotificationTraySlot/)
* [`org.openedx.frontend.learning.notification_widget.v1`](./NotificationWidgetSlot/) * [`org.openedx.frontend.learning.notification_widget.v1`](./NotificationWidgetSlot/)

View File

@@ -27,7 +27,7 @@ const UnitTitleSlot = ({
> >
<div className="d-flex justify-content-between"> <div className="d-flex justify-content-between">
<div className="mb-0"> <div className="mb-0">
<h3 className="h3">{unit.title}</h3> <h1 className="unit-title">{unit.title}</h1>
</div> </div>
{isEnabledOutlineSidebar && renderUnitNavigation(true)} {isEnabledOutlineSidebar && renderUnitNavigation(true)}
</div> </div>

View File

@@ -1,11 +1,11 @@
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export const getUnsubscribeUrl = (userToken, updatePatch) => ( export const getUnsubscribeUrl = (userToken) => (
`${getConfig().LMS_BASE_URL}/api/notifications/preferences/update/${userToken}/${updatePatch}/` `${getConfig().LMS_BASE_URL}/api/notifications/preferences/update/${userToken}/`
); );
export async function unsubscribeNotificationPreferences(userToken, updatePatch) { export async function unsubscribeNotificationPreferences(userToken) {
const url = getUnsubscribeUrl(userToken, updatePatch); const url = getUnsubscribeUrl(userToken);
return getAuthenticatedHttpClient().get(url); return getAuthenticatedHttpClient().get(url);
} }

View File

@@ -17,18 +17,18 @@ import messages from './messages';
const PreferencesUnsubscribe = () => { const PreferencesUnsubscribe = () => {
const intl = useIntl(); const intl = useIntl();
const { userToken, updatePatch } = useParams(); const { userToken } = useParams();
const [status, setStatus] = useState(LOADING); const [status, setStatus] = useState(LOADING);
useEffect(() => { useEffect(() => {
unsubscribeNotificationPreferences(userToken, updatePatch).then( unsubscribeNotificationPreferences(userToken).then(
() => setStatus(LOADED), () => setStatus(LOADED),
(error) => { (error) => {
setStatus(FAILED); setStatus(FAILED);
logError(error); logError(error);
}, },
); );
sendTrackEvent('edx.ui.lms.notifications.preferences.unsubscribe', { userToken, updatePatch }); sendTrackEvent('edx.ui.lms.notifications.preferences.unsubscribe', { userToken });
}, []); }, []);
const pageContent = { const pageContent = {

View File

@@ -24,8 +24,7 @@ describe('Notification Preferences One Click Unsubscribe', () => {
let component; let component;
let store; let store;
const userToken = '1234'; const userToken = '1234';
const updatePatch = 'abc123'; const url = getUnsubscribeUrl(userToken);
const url = getUnsubscribeUrl(userToken, updatePatch);
beforeAll(async () => { beforeAll(async () => {
await initializeTestStore(); await initializeTestStore();
@@ -39,7 +38,7 @@ describe('Notification Preferences One Click Unsubscribe', () => {
component = ( component = (
<AppProvider store={store} wrapWithRouter={false}> <AppProvider store={store} wrapWithRouter={false}>
<UserMessagesProvider> <UserMessagesProvider>
<MemoryRouter initialEntries={[`${`/preferences-unsubscribe/${userToken}/${updatePatch}/`}`]}> <MemoryRouter initialEntries={[`${`/preferences-unsubscribe/${userToken}/`}`]}>
<Routes> <Routes>
<Route path={ROUTES.PREFERENCES_UNSUBSCRIBE} element={<PreferencesUnsubscribe />} /> <Route path={ROUTES.PREFERENCES_UNSUBSCRIBE} element={<PreferencesUnsubscribe />} />
</Routes> </Routes>
@@ -69,7 +68,6 @@ describe('Notification Preferences One Click Unsubscribe', () => {
expect(screen.getByTestId('heading-text')).toHaveTextContent('Error unsubscribing from preference'); expect(screen.getByTestId('heading-text')).toHaveTextContent('Error unsubscribing from preference');
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.notifications.preferences.unsubscribe', { expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.notifications.preferences.unsubscribe', {
userToken, userToken,
updatePatch,
}); });
}); });
}); });

View File

@@ -16,6 +16,7 @@ import {
endCourseHomeTour, endCourseHomeTour,
endCoursewareTour, endCoursewareTour,
fetchTourData, fetchTourData,
openCourseHomeTour,
} from './data'; } from './data';
const ProductTours = ({ const ProductTours = ({
@@ -164,7 +165,7 @@ const ProductTours = ({
is_staff: administrator, is_staff: administrator,
}); });
dispatch(closeNewUserCourseHomeModal()); dispatch(closeNewUserCourseHomeModal());
setIsNewUserCourseHomeTourEnabled(true); dispatch(openCourseHomeTour());
}} }}
/> />
</> </>

View File

@@ -3,6 +3,7 @@ export {
endCourseHomeTour, endCourseHomeTour,
endCoursewareTour, endCoursewareTour,
fetchTourData, fetchTourData,
openCourseHomeTour,
} from './thunks'; } from './thunks';
export { reducer } from './slice'; export { reducer } from './slice';

View File

@@ -6,12 +6,17 @@ import {
disableCoursewareTour, disableCoursewareTour,
disableNewUserCourseHomeModal, disableNewUserCourseHomeModal,
setTourData, setTourData,
launchCourseHomeTour,
} from './slice'; } from './slice';
export function closeNewUserCourseHomeModal() { export function closeNewUserCourseHomeModal() {
return async (dispatch) => dispatch(disableNewUserCourseHomeModal()); return async (dispatch) => dispatch(disableNewUserCourseHomeModal());
} }
export function openCourseHomeTour() {
return async (dispatch) => dispatch(launchCourseHomeTour());
}
export function endCourseHomeTour(username) { export function endCourseHomeTour(username) {
return async (dispatch) => { return async (dispatch) => {
try { try {

View File

@@ -1,3 +1,3 @@
[dir="rtl"] .new-user-tour-dialog .pgn__modal-hero .pgn__modal-hero-bg { [dir="rtl"] .new-user-tour-dialog .pgn__modal-hero .pgn__modal-hero-bg {
transform: scaleX(-1); transform: scaleX(-1);
} }

View File

@@ -116,6 +116,7 @@ export function buildMinimalCourseBlocks(courseId, title, options = {}) {
effort_activities: 2, effort_activities: 2,
effort_time: 15, effort_time: 15,
type: 'sequential', type: 'sequential',
is_preview: false,
}, },
{ courseId }, { courseId },
)]; )];

View File

@@ -1,9 +1,8 @@
/* eslint-disable react/prop-types */ /* eslint-disable react/prop-types */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Lightbulb, MoneyFilled } from '@openedx/paragon/icons'; import { Lightbulb, MoneyFilled } from '@openedx/paragon/icons';
import { import {
@@ -16,7 +15,12 @@ import { useModel } from '../../generic/model-store';
import StreakMobileImage from './assets/Streak_mobile.png'; import StreakMobileImage from './assets/Streak_mobile.png';
import StreakDesktopImage from './assets/Streak_desktop.png'; import StreakDesktopImage from './assets/Streak_desktop.png';
import messages from './messages'; import messages from './messages';
import { recordModalClosing, recordStreakCelebration } from './utils'; import {
calculateVoucherDiscountPercentage,
getDiscountCodePercentage,
recordModalClosing,
recordStreakCelebration,
} from './utils';
function getRandomFactoid(intl, streakLength) { function getRandomFactoid(intl, streakLength) {
const boldedSectionA = intl.formatMessage(messages.streakFactoidABoldedSection); const boldedSectionA = intl.formatMessage(messages.streakFactoidABoldedSection);
@@ -42,13 +46,6 @@ function getRandomFactoid(intl, streakLength) {
return factoids[Math.floor(Math.random() * (factoids.length))]; return factoids[Math.floor(Math.random() * (factoids.length))];
} }
async function calculateVoucherDiscount(voucher, sku, username) {
const urlBase = `${getConfig().ECOMMERCE_BASE_URL}/api/v2/baskets/calculate`;
const url = `${urlBase}/?code=${voucher}&sku=${sku}&username=${username}`;
return getAuthenticatedHttpClient().get(url)
.then(res => camelCaseObject(res));
}
const CloseText = ({ intl }) => ( const CloseText = ({ intl }) => (
<span> <span>
{intl.formatMessage(messages.streakButton)} {intl.formatMessage(messages.streakButton)}
@@ -83,34 +80,38 @@ const StreakModal = ({
// Ask ecommerce to calculate discount savings // Ask ecommerce to calculate discount savings
useEffect(() => { useEffect(() => {
if (streakDiscountCouponEnabled && verifiedMode && getConfig().ECOMMERCE_BASE_URL) { (async () => {
calculateVoucherDiscount(discountCode, verifiedMode.sku, username) let streakDiscountPercentage = 0;
.then( try {
(result) => { if (streakDiscountCouponEnabled && verifiedMode) {
const { totalInclTax, totalInclTaxExclDiscounts } = result.data; // If the discount service is available, use it to get the discount percentage
if (totalInclTaxExclDiscounts && totalInclTax !== totalInclTaxExclDiscounts) { if (getConfig().DISCOUNT_CODE_INFO_URL) {
// Just store the percent (rather than using these values directly), because ecommerce doesn't give us streakDiscountPercentage = await getDiscountCodePercentage(
// the currency symbol to use, so we want to use the symbol that LMS gives us. And I don't want to assume discountCode,
// ecommerce's currency is the same as the LMS. So we'll keep using the values in verifiedMode, just courseId,
// multiplied by the calculated percentage. );
setDiscountPercent(1 - totalInclTax / totalInclTaxExclDiscounts); // If the discount service is not available, fall back to ecommerce to calculate the discount percentage
sendTrackEvent('edx.bi.course.streak_discount_enabled', { } else if (getConfig().ECOMMERCE_BASE_URL) {
course_id: courseId, streakDiscountPercentage = await calculateVoucherDiscountPercentage(
sku: verifiedMode.sku, discountCode,
}); verifiedMode.sku,
} else { username,
setDiscountPercent(0); );
} }
}, }
() => { } catch {
// ignore any errors - we just won't show the discount to the user then // ignore any errors - we just won't show the discount to the user then
setDiscountPercent(0); } finally {
}, if (streakDiscountPercentage) {
); sendTrackEvent('edx.bi.course.streak_discount_enabled', {
} else { course_id: courseId,
setDiscountPercent(0); sku: verifiedMode.sku,
} });
// eslint-disable-next-line react-hooks/exhaustive-deps }
setDiscountPercent(streakDiscountPercentage);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streakDiscountCouponEnabled, username, verifiedMode]); }, [streakDiscountCouponEnabled, username, verifiedMode]);
if (!isStreakCelebrationOpen) { if (!isStreakCelebrationOpen) {

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { camelCaseObject, getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@openedx/paragon'; import { breakpoints } from '@openedx/paragon';
@@ -34,6 +34,19 @@ describe('Loaded Tab Page', () => {
}); });
} }
function setDiscountViaDiscountCodeInfo(percent) {
const discountURLParams = new URLSearchParams();
discountURLParams.append('code', 'ZGY11119949');
discountURLParams.append('course_run_key', courseMetadata.id);
const discountURL = `${getConfig().DISCOUNT_CODE_INFO_URL}?${discountURLParams.toString()}`;
mockData.streakDiscountCouponEnabled = true;
axiosMock.onGet(discountURL).reply(200, {
isApplicable: true,
discountPercentage: percent / 100,
});
}
function setDiscountError() { function setDiscountError() {
mockData.streakDiscountCouponEnabled = true; mockData.streakDiscountCouponEnabled = true;
axiosMock.onGet(calculateUrl).reply(500); axiosMock.onGet(calculateUrl).reply(500);
@@ -105,4 +118,22 @@ describe('Loaded Tab Page', () => {
sku: mockData.verifiedMode.sku, sku: mockData.verifiedMode.sku,
}); });
}); });
it('shows discount version of streak celebration modal when discount available and info fetched using DISCOUNT_CODE_INFO_URL', async () => {
mergeConfig({ DISCOUNT_CODE_INFO_URL: 'http://localhost:8140/lms/discount-code-info/' });
global.innerWidth = breakpoints.extraSmall.maxWidth;
setDiscountViaDiscountCodeInfo(14);
await renderModal();
const endDateText = `Ends ${new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString({ timeZone: 'UTC' })}.`;
expect(screen.getByText('Youve unlocked a 14% off discount when you upgrade this course for a limited time only.', { exact: false })).toBeInTheDocument();
expect(screen.getByText(endDateText, { exact: false })).toBeInTheDocument();
expect(screen.getByText('Continue with course')).toBeInTheDocument();
expect(screen.queryByText('Keep it up')).not.toBeInTheDocument();
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.course.streak_discount_enabled', {
course_id: mockData.courseId,
sku: mockData.verifiedMode.sku,
});
});
}); });

View File

@@ -1,5 +1,9 @@
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import {
getAuthenticatedHttpClient,
getAuthenticatedUser,
} from '@edx/frontend-platform/auth';
import { updateModel } from '../../generic/model-store'; import { updateModel } from '../../generic/model-store';
@@ -24,4 +28,39 @@ function recordModalClosing(celebrations, org, courseId, dispatch) {
})); }));
} }
export { recordStreakCelebration, recordModalClosing }; async function calculateVoucherDiscountPercentage(voucher, sku, username) {
const urlBase = `${getConfig().ECOMMERCE_BASE_URL}/api/v2/baskets/calculate`;
const url = `${urlBase}/?code=${voucher}&sku=${sku}&username=${username}`;
const result = await getAuthenticatedHttpClient().get(url);
const { totalInclTax, totalInclTaxExclDiscounts } = camelCaseObject(result).data;
if (totalInclTaxExclDiscounts && totalInclTax !== totalInclTaxExclDiscounts) {
// Just store the percent (rather than using these values directly), because ecommerce doesn't give us
// the currency symbol to use, so we want to use the symbol that LMS gives us. And I don't want to assume
// ecommerce's currency is the same as the LMS. So we'll keep using the values in verifiedMode, just
// multiplied by the calculated percentage.
return 1 - totalInclTax / totalInclTaxExclDiscounts;
}
return 0;
}
async function getDiscountCodePercentage(code, courseId) {
const params = new URLSearchParams();
params.append('code', code);
params.append('course_run_key', courseId);
const url = `${getConfig().DISCOUNT_CODE_INFO_URL}?${params.toString()}`;
const result = await getAuthenticatedHttpClient().get(url);
const { isApplicable, discountPercentage } = camelCaseObject(result).data;
return isApplicable ? +discountPercentage : 0;
}
export {
calculateVoucherDiscountPercentage,
getDiscountCodePercentage,
recordModalClosing,
recordStreakCelebration,
};