Compare commits
49 Commits
renovate/m
...
jkantor/pt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f369caf2e | ||
|
|
21aecb9634 | ||
|
|
aefa116816 | ||
|
|
31b319b141 | ||
|
|
1f952fc454 | ||
|
|
a0f01cb38a | ||
|
|
f4e88ce9ea | ||
|
|
fb6ad622e2 | ||
|
|
2365dbdd06 | ||
|
|
9f4d82fb5d | ||
|
|
47d491099f | ||
|
|
1832786a5d | ||
|
|
a61bb7c382 | ||
|
|
7123ab7bb1 | ||
|
|
bfefacb940 | ||
|
|
85b0571335 | ||
|
|
c13f118ac2 | ||
|
|
3f41d5a10c | ||
|
|
1b44ee222e | ||
|
|
2728d5d4e9 | ||
|
|
6106b65714 | ||
|
|
8ca5513af4 | ||
|
|
3245198877 | ||
|
|
74257bc1f4 | ||
|
|
7656e602b6 | ||
|
|
69a443a571 | ||
|
|
2bfea2823b | ||
|
|
35a0a6456c | ||
|
|
24a9a6a761 | ||
|
|
0caa243a2e | ||
|
|
724039c629 | ||
|
|
e82132df5f | ||
|
|
3846f1eae5 | ||
|
|
11698e055f | ||
|
|
7817ac751c | ||
|
|
0dfbca7cd8 | ||
|
|
5e922a1643 | ||
|
|
60f9abbe2b | ||
|
|
118d5aac31 | ||
|
|
a8e2c080dc | ||
|
|
f0f482cc32 | ||
|
|
dbe917f692 | ||
|
|
69f1ca5a99 | ||
|
|
d4de38a8e7 | ||
|
|
6736e6cd26 | ||
|
|
b6ab78c244 | ||
|
|
662783dbd4 | ||
|
|
b315c0b1e6 | ||
|
|
241e188465 |
3
.env
3
.env
@@ -12,6 +12,7 @@ CREDIT_HELP_LINK_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
DISCUSSIONS_MFE_BASE_URL=''
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
@@ -50,3 +51,5 @@ TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -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'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
@@ -52,3 +53,5 @@ CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -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'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
@@ -50,3 +51,4 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:Enterprise'
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'
|
||||
|
||||
5
Makefile
5
Makefile
@@ -40,9 +40,10 @@ pull_translations:
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-lib-special-exams/src/i18n/messages:frontend-lib-special-exams \
|
||||
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
|
||||
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning \
|
||||
$(ATLAS_EXTRA_SOURCES)
|
||||
|
||||
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning
|
||||
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning $(ATLAS_EXTRA_INTL_IMPORTS)
|
||||
|
||||
|
||||
# This target is used by Travis.
|
||||
|
||||
1196
package-lock.json
generated
1196
package-lock.json
generated
@@ -13,7 +13,7 @@
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.20.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.24.0",
|
||||
"@edx/frontend-lib-special-exams": "^4.0.0",
|
||||
"@edx/frontend-platform": "^8.3.1",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
@@ -24,7 +24,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@openedx/frontend-build": "^14.5.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -1940,6 +1940,158 @@
|
||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||
"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": {
|
||||
"version": "4.2.0",
|
||||
"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": {
|
||||
"version": "2.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-2.21.0.tgz",
|
||||
"integrity": "sha512-CUzPCQaBgXi6E1kvY0nyBSVFu8RUGpwKH4V0p8ZuysyHyRHpA+339b+gEi9FvVBMP/X4IxZHsZhi7nphlr43Iw==",
|
||||
"version": "2.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-2.24.0.tgz",
|
||||
"integrity": "sha512-+RwmKbYxsJ6Ct9scBX3jnxSUuoiW5ed1vbCz9PQiQ8fobuiMM3fokLynIreB5ZVYWvrjSa5OaMwBq1bUXsprZw==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
@@ -3154,6 +3306,102 @@
|
||||
"deprecated": "Use @eslint/object-schema instead",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
@@ -4137,9 +4439,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon": {
|
||||
"version": "22.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.17.0.tgz",
|
||||
"integrity": "sha512-MzOLQ0myaOErwumPJwxVZXTw7zJKrARtu4YMSaISF5Sz6pE1/dYz9qfRcqaraYRcJGNdbPRzOG0v3iqbZo1uHQ==",
|
||||
"version": "23.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.4.5.tgz",
|
||||
"integrity": "sha512-baBTZDO6hdCjI+Jj3oSQaz5GkFTR2TEs94pPMOls7bc/Fsv4zQIN8xDPo4NzAVo/2+3eSuEzUz3xzBeb+94rtw==",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"example",
|
||||
@@ -4149,20 +4451,32 @@
|
||||
"dependent-usage-analyzer"
|
||||
],
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.18",
|
||||
"@popperjs/core": "^2.11.4",
|
||||
"@tokens-studio/sd-transforms": "^1.2.4",
|
||||
"axios": "^0.27.2",
|
||||
"bootstrap": "^4.6.2",
|
||||
"chalk": "^4.1.2",
|
||||
"child_process": "^1.0.2",
|
||||
"chroma-js": "^2.4.2",
|
||||
"classnames": "^2.3.1",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^9.4.1",
|
||||
"email-prop-type": "^3.0.0",
|
||||
"file-selector": "^0.6.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
"glob": "^8.0.3",
|
||||
"inquirer": "^8.2.5",
|
||||
"js-toml": "^1.0.0",
|
||||
"lodash.uniqby": "^4.7.0",
|
||||
"log-update": "^4.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",
|
||||
"react-bootstrap": "^1.6.5",
|
||||
"react-colorful": "^5.6.1",
|
||||
@@ -4175,6 +4489,8 @@
|
||||
"react-responsive": "^8.2.0",
|
||||
"react-table": "^7.7.0",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"sass": "^1.58.3",
|
||||
"style-dictionary": "^4.3.2",
|
||||
"tabbable": "^5.3.3",
|
||||
"uncontrollable": "^7.2.1",
|
||||
"uuid": "^9.0.0"
|
||||
@@ -4188,6 +4504,16 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
@@ -4197,6 +4523,15 @@
|
||||
"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": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||
@@ -4229,6 +4564,34 @@
|
||||
"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": {
|
||||
"version": "0.3.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.5.15",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
@@ -6504,6 +6903,23 @@
|
||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||
"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": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
@@ -6955,6 +7371,19 @@
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/assert-ok/-/assert-ok-1.0.0.tgz",
|
||||
@@ -6967,6 +7396,15 @@
|
||||
"integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==",
|
||||
"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": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@@ -7934,6 +8372,12 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
|
||||
@@ -8077,6 +8521,20 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz",
|
||||
@@ -8113,6 +8571,12 @@
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"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": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
||||
@@ -8204,6 +8668,18 @@
|
||||
"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": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
|
||||
@@ -8348,6 +8824,12 @@
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -9656,6 +10138,12 @@
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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_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": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
@@ -11479,6 +11973,15 @@
|
||||
"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": {
|
||||
"version": "5.0.2",
|
||||
"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": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -11580,6 +12074,34 @@
|
||||
"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": {
|
||||
"version": "6.5.3",
|
||||
"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_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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
|
||||
@@ -13039,6 +13570,22 @@
|
||||
"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": {
|
||||
"version": "3.0.5",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -13818,6 +14387,21 @@
|
||||
"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": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
|
||||
@@ -14792,6 +15376,16 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"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": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
@@ -15037,6 +15631,15 @@
|
||||
"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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -15178,6 +15781,12 @@
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"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": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
|
||||
@@ -15332,6 +15941,24 @@
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -16191,6 +16818,15 @@
|
||||
"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": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -16492,6 +17128,22 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
@@ -16829,6 +17481,12 @@
|
||||
"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": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||
@@ -16900,6 +17558,95 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -16939,6 +17686,28 @@
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"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": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
@@ -16954,6 +17723,27 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -17357,6 +18147,24 @@
|
||||
"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": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz",
|
||||
@@ -17375,6 +18183,21 @@
|
||||
"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": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz",
|
||||
@@ -17467,6 +18290,23 @@
|
||||
"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": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz",
|
||||
@@ -17554,6 +18394,52 @@
|
||||
"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": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz",
|
||||
@@ -17588,6 +18474,19 @@
|
||||
"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": {
|
||||
"version": "6.1.0",
|
||||
"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_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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
|
||||
@@ -18100,7 +19014,6 @@
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
@@ -19165,6 +20078,24 @@
|
||||
"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": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
|
||||
@@ -19292,6 +20223,15 @@
|
||||
"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": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
@@ -20508,6 +21448,23 @@
|
||||
"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": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
|
||||
@@ -20766,6 +21723,27 @@
|
||||
"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": {
|
||||
"version": "2.22.0",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
|
||||
@@ -20824,6 +21802,27 @@
|
||||
"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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -20925,6 +21924,19 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
|
||||
@@ -20967,6 +21979,55 @@
|
||||
"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": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz",
|
||||
@@ -21345,6 +22406,18 @@
|
||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||
"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": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz",
|
||||
@@ -21379,6 +22452,12 @@
|
||||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
|
||||
"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": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||
@@ -21505,6 +22584,22 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -22197,6 +23292,19 @@
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
|
||||
@@ -22252,6 +23360,12 @@
|
||||
"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": {
|
||||
"version": "1.3.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -23063,6 +24190,24 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -23118,6 +24263,15 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"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": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.20.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.24.0",
|
||||
"@edx/frontend-lib-special-exams": "^4.0.0",
|
||||
"@edx/frontend-platform": "^8.3.1",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@openedx/frontend-build": "^14.5.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"buffer": "^6.0.3",
|
||||
|
||||
@@ -68,7 +68,7 @@ exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
|
||||
<Preferences Unsubscribe />
|
||||
</PageWrap>
|
||||
}
|
||||
path="/preferences-unsubscribe/:userToken/:updatePatch"
|
||||
path="/preferences-unsubscribe/:userToken/:updatePatch?"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
|
||||
@@ -22,7 +22,7 @@ export const DECODE_ROUTES = {
|
||||
|
||||
export const ROUTES = {
|
||||
UNSUBSCRIBE: '/goal-unsubscribe/:token',
|
||||
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch',
|
||||
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch?',
|
||||
REDIRECT: '/redirect/*',
|
||||
DASHBOARD: 'dashboard',
|
||||
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
border-top: 1px solid $light-300;
|
||||
z-index: $zindex-modal; // Bootstrap's z-index layer for Modals.
|
||||
border-top: 1px solid var(--pgn-color-light-300);
|
||||
z-index: var(--pgn-elevation-modal-zindex); // Bootstrap's z-index layer for Modals.
|
||||
|
||||
&__form {
|
||||
position: relative;
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
&__results-summary {
|
||||
font-size: .9rem;
|
||||
color: $gray-500;
|
||||
color: var(--pgn-color-gray-500);
|
||||
padding: 1rem 0 .5rem;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
margin-top: 1.5rem;
|
||||
|
||||
&__empty {
|
||||
color: $gray-500;
|
||||
color: var(--pgn-color-gray-500);
|
||||
padding: 6rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -76,17 +76,17 @@
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: $light-300;
|
||||
background: var(--pgn-color-light-300);
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid $light-300;
|
||||
border-top: 1px solid var(--pgn-color-light-300);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
padding: 0.375rem 0 0 0.375rem;
|
||||
color: $gray-300;
|
||||
color: var(--pgn-color-gray-300);
|
||||
}
|
||||
|
||||
&__info {
|
||||
@@ -99,7 +99,7 @@
|
||||
align-items: center;
|
||||
line-height: 2.5;
|
||||
font-size: 0.875rem;
|
||||
color: $black;
|
||||
color: var(--pgn-color-black);
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
@@ -113,7 +113,7 @@
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
min-width: 1.25rem;
|
||||
line-height: 1rem;
|
||||
background: $light-300;
|
||||
background: var(--pgn-color-light-300);
|
||||
border-radius: 99rem;
|
||||
font-style: normal;
|
||||
margin-left: 0.375rem;
|
||||
@@ -125,7 +125,7 @@
|
||||
&__breadcrumbs {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
color: $gray-500;
|
||||
color: var(--pgn-color-gray-500);
|
||||
overflow: hidden;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -156,14 +156,14 @@
|
||||
}
|
||||
|
||||
.courseware-search-results-tabs {
|
||||
border-bottom-color: $gray-400 !important;
|
||||
border-bottom-color: var(--pgn-color-gray-400) !important;
|
||||
|
||||
&.nav-tabs .nav-link.active {
|
||||
border-bottom-width: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, 'md')) {
|
||||
@media (--pgn-size-breakpoint-min-width-md) {
|
||||
.courseware-search {
|
||||
&__close {
|
||||
right: -2.5rem;
|
||||
|
||||
@@ -17,7 +17,21 @@ Factory.define('progressTabData')
|
||||
percent: 1,
|
||||
is_passing: true,
|
||||
},
|
||||
final_grades: 0.5,
|
||||
credit_course_requirements: null,
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Homework',
|
||||
short_label: 'HW',
|
||||
weight: 1,
|
||||
average_grade: 1,
|
||||
weighted_grade: 1,
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
has_hidden_contribution: 'none',
|
||||
last_grade_publish_date: null,
|
||||
},
|
||||
],
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
|
||||
@@ -5,6 +5,7 @@ exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"examsData": null,
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
@@ -397,6 +398,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"examsData": null,
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
@@ -528,6 +530,7 @@ exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normali
|
||||
"hideFromTOC": undefined,
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"isPreview": false,
|
||||
"navigationDisabled": undefined,
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
@@ -669,6 +672,7 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"courseHome": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseStatus": "loaded",
|
||||
"examsData": null,
|
||||
"proctoringPanelStatus": "loading",
|
||||
"showSearch": false,
|
||||
"targetUserId": undefined,
|
||||
@@ -761,6 +765,19 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"progress": {
|
||||
"course-v1:edX+DemoX+Demo_Course": {
|
||||
"accessExpiration": null,
|
||||
"assignmentTypeGradeSummary": [
|
||||
{
|
||||
"averageGrade": 1,
|
||||
"hasHiddenContribution": "none",
|
||||
"lastGradePublishDate": null,
|
||||
"numDroppable": 1,
|
||||
"numTotal": 2,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
"weight": 1,
|
||||
"weightedGrade": 1,
|
||||
},
|
||||
],
|
||||
"certificateData": {},
|
||||
"completionSummary": {
|
||||
"completeCount": 1,
|
||||
@@ -776,17 +793,17 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal
|
||||
"creditCourseRequirements": null,
|
||||
"end": "3027-03-31T00:00:00Z",
|
||||
"enrollmentMode": "audit",
|
||||
"finalGrades": 0.5,
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
"gradesFeatureIsPartiallyLocked": false,
|
||||
"gradingPolicy": {
|
||||
"assignmentPolicies": [
|
||||
{
|
||||
"averageGrade": "1.0000",
|
||||
"numDroppable": 1,
|
||||
"numTotal": 2,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
"weight": 1,
|
||||
"weightedGrade": 1,
|
||||
},
|
||||
],
|
||||
"gradeRange": {
|
||||
|
||||
@@ -3,93 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logInfo } from '@edx/frontend-platform/logging';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
|
||||
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
|
||||
let dropCount = numDroppable;
|
||||
// Drop the lowest grades
|
||||
while (dropCount && points.length >= dropCount) {
|
||||
const lowestScore = Math.min(...points);
|
||||
const lowestScoreIndex = points.indexOf(lowestScore);
|
||||
points.splice(lowestScoreIndex, 1);
|
||||
dropCount--;
|
||||
}
|
||||
let averageGrade = 0;
|
||||
let weightedGrade = 0;
|
||||
if (points.length) {
|
||||
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
||||
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
||||
// exists in edx-platform.
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
};
|
||||
|
||||
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
const gradeByAssignmentType = {};
|
||||
assignmentPolicies.forEach(assignment => {
|
||||
// Create an array with the number of total assignments and set the scores to 0
|
||||
// as placeholders for assignments that have not yet been released
|
||||
gradeByAssignmentType[assignment.type] = {
|
||||
grades: Array(assignment.numTotal).fill(0),
|
||||
numAssignmentsCreated: 0,
|
||||
numTotalExpectedAssignments: assignment.numTotal,
|
||||
};
|
||||
});
|
||||
|
||||
sectionScores.forEach((chapter) => {
|
||||
chapter.subsections.forEach((subsection) => {
|
||||
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
assignmentType,
|
||||
numPointsEarned,
|
||||
numPointsPossible,
|
||||
} = subsection;
|
||||
|
||||
// If a subsection's assignment type does not match an assignment policy in Studio,
|
||||
// we won't be able to include it in this accumulation of grades by assignment type.
|
||||
// This may happen if a course author has removed/renamed an assignment policy in Studio and
|
||||
// neglected to update the subsection's of that assignment type
|
||||
if (!gradeByAssignmentType[assignmentType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
numAssignmentsCreated,
|
||||
} = gradeByAssignmentType[assignmentType];
|
||||
|
||||
numAssignmentsCreated++;
|
||||
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
|
||||
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
|
||||
// of expected assignments
|
||||
gradeByAssignmentType[assignmentType].grades.shift();
|
||||
}
|
||||
// Add the graded assignment to the list
|
||||
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
|
||||
// Record the created assignment
|
||||
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
|
||||
});
|
||||
});
|
||||
|
||||
return assignmentPolicies.map((assignment) => {
|
||||
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
|
||||
gradeByAssignmentType[assignment.type].grades,
|
||||
assignment.weight,
|
||||
assignment.numDroppable,
|
||||
);
|
||||
|
||||
return {
|
||||
averageGrade,
|
||||
numDroppable: assignment.numDroppable,
|
||||
shortLabel: assignment.shortLabel,
|
||||
type: assignment.type,
|
||||
weight: assignment.weight,
|
||||
weightedGrade,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tweak the metadata for consistency
|
||||
* @param metadata the data to normalize
|
||||
@@ -155,6 +68,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
title: block.display_name,
|
||||
hideFromTOC: block.hide_from_toc,
|
||||
navigationDisabled: block.navigation_disabled,
|
||||
isPreview: block.is_preview,
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -236,11 +150,6 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
const camelCasedData = camelCaseObject(data);
|
||||
|
||||
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
|
||||
camelCasedData.gradingPolicy.assignmentPolicies,
|
||||
camelCasedData.sectionScores,
|
||||
);
|
||||
|
||||
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
||||
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
||||
// in order to preserve a course team's desired grade formatting.
|
||||
@@ -471,3 +380,24 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option
|
||||
|
||||
return camelCaseObject(response);
|
||||
}
|
||||
|
||||
export async function getExamsData(courseId, sequenceId) {
|
||||
let url;
|
||||
|
||||
if (!getConfig().EXAMS_BASE_URL) {
|
||||
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
} else {
|
||||
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { getTimeOffsetMillis } from './api';
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getTimeOffsetMillis, getExamsData } from './api';
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Calculate the time offset properly', () => {
|
||||
it('Should return 0 if the headerDate is not set', async () => {
|
||||
@@ -14,3 +22,156 @@ describe('Calculate the time offset properly', () => {
|
||||
expect(offset).toBe(86398750);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExamsData', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345';
|
||||
let originalConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.reset();
|
||||
originalConfig = getConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
if (originalConfig) {
|
||||
setConfig(originalConfig);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use LMS URL when EXAMS_BASE_URL is not configured', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'created',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({
|
||||
exam: {
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceId,
|
||||
examName: 'Test Exam',
|
||||
attemptStatus: 'created',
|
||||
},
|
||||
});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('should use EXAMS_BASE_URL when configured', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'submitted',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({
|
||||
exam: {
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceId,
|
||||
examName: 'Test Exam',
|
||||
attemptStatus: 'submitted',
|
||||
},
|
||||
});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('should return empty object when API returns 404', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
|
||||
// Mock a 404 error with the custom error response function to add customAttributes
|
||||
axiosMock.onGet(expectedUrl).reply(() => {
|
||||
const error = new Error('Request failed with status code 404');
|
||||
error.response = { status: 404, data: {} };
|
||||
error.customAttributes = { httpErrorStatus: 404 };
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw error for non-404 HTTP errors', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
|
||||
// Mock a 500 error with custom error response
|
||||
axiosMock.onGet(expectedUrl).reply(() => {
|
||||
const error = new Error('Request failed with status code 500');
|
||||
error.response = { status: 500, data: { error: 'Server Error' } };
|
||||
error.customAttributes = { httpErrorStatus: 500 };
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
await expect(getExamsData(courseId, sequenceId)).rejects.toThrow();
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should properly encode URL parameters', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const specialCourseId = 'course-v1:edX+Demo X+Demo Course';
|
||||
const specialSequenceId = 'block-v1:edX+Demo X+Demo Course+type@sequential+block@test sequence';
|
||||
|
||||
const mockExamData = { exam: { id: 1 } };
|
||||
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(specialCourseId)}/content_id/${encodeURIComponent(specialSequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
await getExamsData(specialCourseId, specialSequenceId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
expect(axiosMock.history.get[0].url).toContain('course-v1%3AedX%2BDemo%20X%2BDemo%20Course');
|
||||
expect(axiosMock.history.get[0].url).toContain('block-v1%3AedX%2BDemo%20X%2BDemo%20Course%2Btype%40sequential%2Bblock%40test%20sequence');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,4 +297,178 @@ describe('Data layer integration tests', () => {
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchExamAttemptsData', () => {
|
||||
const sequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@abcde',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock individual exam endpoints with different responses
|
||||
sequenceIds.forEach((sequenceId, index) => {
|
||||
// Handle both LMS and EXAMS service URL patterns
|
||||
const lmsExamUrl = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceId)}.*`);
|
||||
const examsServiceUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
|
||||
|
||||
let attemptStatus = 'ready_to_start';
|
||||
if (index === 0) {
|
||||
attemptStatus = 'created';
|
||||
} else if (index === 1) {
|
||||
attemptStatus = 'submitted';
|
||||
}
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: index + 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: `Test Exam ${index + 1}`,
|
||||
attempt_status: attemptStatus,
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
};
|
||||
|
||||
// Mock both URL patterns
|
||||
axiosMock.onGet(lmsExamUrl).reply(200, mockExamData);
|
||||
axiosMock.onGet(examsServiceUrl).reply(200, mockExamData);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch exam data for all sequence IDs and dispatch setExamsData', async () => {
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify the examsData was set in the store
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceIds[0],
|
||||
examName: 'Test Exam 1',
|
||||
attemptStatus: 'created',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
courseId,
|
||||
contentId: sequenceIds[1],
|
||||
examName: 'Test Exam 2',
|
||||
attemptStatus: 'submitted',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
courseId,
|
||||
contentId: sequenceIds[2],
|
||||
examName: 'Test Exam 3',
|
||||
attemptStatus: 'ready_to_start',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
]);
|
||||
|
||||
// Verify all API calls were made
|
||||
expect(axiosMock.history.get).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle 404 responses and include empty objects in results', async () => {
|
||||
// Override one endpoint to return 404 for both URL patterns
|
||||
const examUrl404LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl404Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
axiosMock.onGet(examUrl404LMS).reply(404);
|
||||
axiosMock.onGet(examUrl404Exams).reply(404);
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify the examsData includes empty object for 404 response
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[1]).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle API errors and log them while continuing with other requests', async () => {
|
||||
// Override one endpoint to return 500 error for both URL patterns
|
||||
const examUrl500LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl500Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
axiosMock.onGet(examUrl500LMS).reply(500, { error: 'Server Error' });
|
||||
axiosMock.onGet(examUrl500Exams).reply(500, { error: 'Server Error' });
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify error was logged for the failed request
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
|
||||
// Verify the examsData still includes results for successful requests
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
// First item should be the error result (just empty object for API errors)
|
||||
expect(state.courseHome.examsData[0]).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle empty sequence IDs array', async () => {
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, []), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseHome.examsData).toEqual([]);
|
||||
expect(axiosMock.history.get).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle mixed success and error responses', async () => {
|
||||
// Setup mixed responses
|
||||
const examUrl1LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl1Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl2LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl2Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl3LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[2])}.*`);
|
||||
const examUrl3Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[2])}.*`);
|
||||
|
||||
axiosMock.onGet(examUrl1LMS).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
exam_name: 'Success Exam',
|
||||
course_id: courseId,
|
||||
content_id: sequenceIds[0],
|
||||
attempt_status: 'created',
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
});
|
||||
axiosMock.onGet(examUrl1Exams).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
exam_name: 'Success Exam',
|
||||
course_id: courseId,
|
||||
content_id: sequenceIds[0],
|
||||
attempt_status: 'created',
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
});
|
||||
axiosMock.onGet(examUrl2LMS).reply(404);
|
||||
axiosMock.onGet(examUrl2Exams).reply(404);
|
||||
axiosMock.onGet(examUrl3LMS).reply(500, { error: 'Server Error' });
|
||||
axiosMock.onGet(examUrl3Exams).reply(500, { error: 'Server Error' });
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[0]).toMatchObject({
|
||||
id: 1,
|
||||
examName: 'Success Exam',
|
||||
courseId,
|
||||
contentId: sequenceIds[0],
|
||||
});
|
||||
expect(state.courseHome.examsData[1]).toEqual({});
|
||||
expect(state.courseHome.examsData[2]).toEqual({});
|
||||
|
||||
// Verify error was logged for the 500 error (may be called more than once due to multiple URL patterns)
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ const slice = createSlice({
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchProctoringInfoResolved: (state) => {
|
||||
@@ -53,6 +54,9 @@ const slice = createSlice({
|
||||
setShowSearch: (state, { payload }) => {
|
||||
state.showSearch = payload;
|
||||
},
|
||||
setExamsData: (state, { payload }) => {
|
||||
state.examsData = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,6 +68,7 @@ export const {
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setShowSearch,
|
||||
setExamsData,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
145
src/course-home/data/slice.test.js
Normal file
145
src/course-home/data/slice.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
executePostFromPostEvent,
|
||||
getCourseHomeCourseMetadata,
|
||||
getDatesTabData,
|
||||
getExamsData,
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setExamsData,
|
||||
} from './slice';
|
||||
|
||||
import mapSearchResponse from '../courseware-search/map-search-response';
|
||||
@@ -223,3 +225,19 @@ export function searchCourseContent(courseId, searchKeyword) {
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchExamAttemptsData(courseId, sequenceIds) {
|
||||
return async (dispatch) => {
|
||||
const results = await Promise.all(sequenceIds.map(async (sequenceId) => {
|
||||
try {
|
||||
const response = await getExamsData(courseId, sequenceId);
|
||||
return response.exam || {};
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return {};
|
||||
}
|
||||
}));
|
||||
|
||||
dispatch(setExamsData(results));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ const OutlineTab = () => {
|
||||
</div>
|
||||
{rootCourseId && (
|
||||
<div className="col col-12 col-md-4">
|
||||
<CourseOutlineTabNotificationsSlot courseId={courseId} />
|
||||
<ProctoringInfoPanel />
|
||||
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as
|
||||
disabled to avoid components bouncing around too much as screen is rendered */ }
|
||||
@@ -181,7 +182,6 @@ const OutlineTab = () => {
|
||||
/>
|
||||
)}
|
||||
<CourseTools />
|
||||
<CourseOutlineTabNotificationsSlot courseId={courseId} />
|
||||
<CourseDates />
|
||||
<CourseHandouts />
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@openedx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
|
||||
.flag-button {
|
||||
background-color: $white;
|
||||
border: 1px solid $light-400;
|
||||
background-color: var(--pgn-color-white);
|
||||
border: 1px solid var(--pgn-color-light-400);
|
||||
border-radius: .2rem;
|
||||
box-shadow: 0 0 0 2px $light-400;
|
||||
box-shadow: 0 0 0 2px var(--pgn-color-light-400);
|
||||
|
||||
&:hover {
|
||||
border: 1px solid $primary-300;
|
||||
box-shadow: 0 0 0 2px $white;
|
||||
border: 1px solid var(--pgn-color-primary-300);
|
||||
box-shadow: 0 0 0 2px var(--pgn-color-white);
|
||||
}
|
||||
}
|
||||
|
||||
.flag-button-selected {
|
||||
border: 1px solid $primary-300;
|
||||
box-shadow: 0 0 0 2px $primary-300;
|
||||
border: 1px solid var(--pgn-color-primary-300);
|
||||
box-shadow: 0 0 0 2px var(--pgn-color-primary-300);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.outline-sidebar-proctoring-panel {
|
||||
border: 1px solid $dark-500;
|
||||
border-top: 5px solid $brand-600;
|
||||
border: 1px solid var(--pgn-color-dark-500);
|
||||
border-top: 5px solid var(--pgn-color-brand-600);
|
||||
}
|
||||
.proctoring-onboarding-success {
|
||||
border-top: 5px solid $primary-500;
|
||||
border-top: 5px solid var(--pgn-color-primary-500);
|
||||
}
|
||||
.proctoring-onboarding-submitted {
|
||||
border-top: 5px solid $dark-500;
|
||||
border-top: 5px solid var(--pgn-color-dark-500);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
import { useContextId } from '../../data/hooks';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot';
|
||||
|
||||
import CourseCompletion from './course-completion/CourseCompletion';
|
||||
@@ -10,11 +11,17 @@ import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/Progres
|
||||
import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot';
|
||||
import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot';
|
||||
import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useGetExamsData } from './hooks';
|
||||
|
||||
const ProgressTab = () => {
|
||||
const courseId = useContextId();
|
||||
const { disableProgressGraph } = useModel('progress', courseId);
|
||||
const { disableProgressGraph, sectionScores } = useModel('progress', courseId);
|
||||
|
||||
const sequenceIds = useMemo(() => (
|
||||
sectionScores.flatMap((section) => (section.subsections)).map((subsection) => subsection.blockKey)
|
||||
), [sectionScores]);
|
||||
|
||||
useGetExamsData(courseId, sequenceIds);
|
||||
|
||||
const windowWidth = useWindowSize().width;
|
||||
if (windowWidth === undefined) {
|
||||
|
||||
@@ -661,143 +661,133 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Grade Summary when assignment policies are not populated', async () => {
|
||||
it('does not render Grade Summary when assignment type grade summary is not populated', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
section_scores: [],
|
||||
assignment_type_grade_summary: [],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => {
|
||||
it('shows lock icon when all subsections of assignment type are hidden', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 2,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of droppable assignments is zero', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates weighted grades correctly', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 0.5,
|
||||
},
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 1,
|
||||
short_label: 'Ex',
|
||||
type: 'Exam',
|
||||
weight: 0.5,
|
||||
short_label: 'Final',
|
||||
type: 'Final Exam',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Final Exam',
|
||||
weight: 0.4,
|
||||
average_grade: 0.0,
|
||||
weighted_grade: 0.0,
|
||||
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
|
||||
has_hidden_contribution: 'all',
|
||||
short_label: 'Final',
|
||||
num_droppable: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
|
||||
// Should show lock icon for grade and weighted grade
|
||||
expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
average_grade: 0.25,
|
||||
weighted_grade: 0.25,
|
||||
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
|
||||
has_hidden_contribution: 'some',
|
||||
short_label: 'HW',
|
||||
num_droppable: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
// Should show percent + hidden scores for grade and weighted grade
|
||||
const hiddenScoresCells = screen.getAllByText(/% \+ Hidden Scores/);
|
||||
expect(hiddenScoresCells).toHaveLength(2);
|
||||
// Only correct visible scores should be shown (from subsection2)
|
||||
// The correct visible score is 1/4 = 0.25 -> 25%
|
||||
expect(hiddenScoresCells[0]).toHaveTextContent('25% + Hidden Scores');
|
||||
expect(hiddenScoresCells[1]).toHaveTextContent('25% + Hidden Scores');
|
||||
});
|
||||
|
||||
it('displays a warning message with the latest due date when not all assignment scores are included in the total grade', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
average_grade: 1,
|
||||
weighted_grade: 1,
|
||||
last_grade_publish_date: tomorrow.toISOString(),
|
||||
has_hidden_contribution: 'none',
|
||||
short_label: 'HW',
|
||||
num_droppable: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
const formattedDateTime = new Intl.DateTimeFormat('en', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short',
|
||||
}).format(tomorrow);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Some assignment scores are not yet included in your total grade. These grades will be released by ${formattedDateTime}.`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders override notice', async () => {
|
||||
@@ -1500,4 +1490,287 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exam data fetching integration', () => {
|
||||
const mockSectionScores = [
|
||||
{
|
||||
display_name: 'Section 1',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
display_name: 'Midterm Exam',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.8,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@homework1',
|
||||
display_name: 'Homework 1',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.9,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Section 2',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
display_name: 'Final Exam',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.85,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset any existing handlers to avoid conflicts
|
||||
axiosMock.reset();
|
||||
|
||||
// Re-add the base mocks that other tests expect
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
|
||||
|
||||
// Mock exam data endpoints using specific GET handlers
|
||||
axiosMock.onGet(/.*exam1.*/).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
exam_name: 'Midterm Exam',
|
||||
attempt_status: 'submitted',
|
||||
time_remaining_seconds: 0,
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock.onGet(/.*homework1.*/).reply(404);
|
||||
|
||||
axiosMock.onGet(/.*final_exam.*/).reply(200, {
|
||||
exam: {
|
||||
id: 2,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
exam_name: 'Final Exam',
|
||||
attempt_status: 'ready_to_start',
|
||||
time_remaining_seconds: 7200,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch exam data for all subsections when ProgressTab renders', async () => {
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify exam API calls were made for all subsections
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
|
||||
// Verify the exam data is in the Redux store
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
|
||||
// Check the exam data structure
|
||||
expect(state.courseHome.examsData[0]).toEqual({
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
examName: 'Midterm Exam',
|
||||
attemptStatus: 'submitted',
|
||||
timeRemainingSeconds: 0,
|
||||
});
|
||||
|
||||
expect(state.courseHome.examsData[1]).toEqual({}); // 404 response for homework
|
||||
|
||||
expect(state.courseHome.examsData[2]).toEqual({
|
||||
id: 2,
|
||||
courseId,
|
||||
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
examName: 'Final Exam',
|
||||
attemptStatus: 'ready_to_start',
|
||||
timeRemainingSeconds: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty section scores gracefully', async () => {
|
||||
setTabData({ section_scores: [] });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify no exam API calls were made
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(0);
|
||||
|
||||
// Verify empty exam data in Redux store
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toEqual([]);
|
||||
});
|
||||
|
||||
it('should re-fetch exam data when section scores change', async () => {
|
||||
// Initial render with limited section scores
|
||||
setTabData({
|
||||
section_scores: [mockSectionScores[0]], // Only first section
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify initial API calls (2 subsections in first section)
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(2);
|
||||
|
||||
// Clear axios history to track new calls
|
||||
axiosMock.resetHistory();
|
||||
|
||||
// Update with full section scores and re-render
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
// Verify additional API calls for all subsections
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle exam API errors gracefully without breaking ProgressTab', async () => {
|
||||
// Clear existing mocks and setup specific error scenario
|
||||
axiosMock.reset();
|
||||
|
||||
// Re-add base mocks
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
|
||||
|
||||
// Mock first exam to return 500 error
|
||||
axiosMock.onGet(/.*exam1.*/).reply(500, { error: 'Server Error' });
|
||||
|
||||
// Mock other exams to succeed
|
||||
axiosMock.onGet(/.*homework1.*/).reply(404, { customAttributes: { httpErrorStatus: 404 } });
|
||||
axiosMock.onGet(/.*final_exam.*/).reply(200, {
|
||||
exam: {
|
||||
id: 2,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
exam_name: 'Final Exam',
|
||||
attempt_status: 'ready_to_start',
|
||||
time_remaining_seconds: 7200,
|
||||
},
|
||||
});
|
||||
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify ProgressTab still renders successfully despite API error
|
||||
expect(screen.getByText('Grades')).toBeInTheDocument();
|
||||
|
||||
// Verify the exam data includes error placeholder for failed request
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[0]).toEqual({}); // Failed request returns empty object
|
||||
});
|
||||
|
||||
it('should use EXAMS_BASE_URL when configured for exam API calls', async () => {
|
||||
// Configure EXAMS_BASE_URL
|
||||
const originalConfig = getConfig();
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
});
|
||||
|
||||
// Override mock to use new base URL
|
||||
const examUrlWithExamsBase = /http:\/\/localhost:18740\/api\/v1\/student\/exam\/attempt\/course_id.*/;
|
||||
axiosMock.onGet(examUrlWithExamsBase).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'created',
|
||||
},
|
||||
});
|
||||
|
||||
setTabData({ section_scores: [mockSectionScores[0]] });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify API calls use EXAMS_BASE_URL
|
||||
const examApiCalls = axiosMock.history.get.filter(req => req.url.includes('localhost:18740'));
|
||||
expect(examApiCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Restore original config
|
||||
setConfig(originalConfig);
|
||||
});
|
||||
|
||||
it('should extract sequence IDs correctly from nested section scores structure', async () => {
|
||||
const complexSectionScores = [
|
||||
{
|
||||
display_name: 'Introduction',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Lecture',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
|
||||
display_name: 'Course Introduction',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Assessments',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
|
||||
display_name: 'Quiz 1',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
|
||||
display_name: 'Quiz 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Mock all the expected sequence IDs
|
||||
const expectedSequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
|
||||
];
|
||||
|
||||
expectedSequenceIds.forEach((sequenceId, index) => {
|
||||
const examUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
|
||||
axiosMock.onGet(examUrl).reply(index === 0 ? 404 : 200, {
|
||||
exam: {
|
||||
id: index,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: `Test ${index}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
setTabData({ section_scores: complexSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify API calls were made for all extracted sequence IDs
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
|
||||
// Verify correct sequence IDs were used in API calls
|
||||
const apiCalls = axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'));
|
||||
expectedSequenceIds.forEach(sequenceId => {
|
||||
expect(apiCalls.some(call => call.url.includes(encodeURIComponent(sequenceId)))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
|
||||
.donut-chart-label {
|
||||
font: {
|
||||
family: $font-family-sans-serif;
|
||||
family: var(--pgn-typography-font-family-sans-serif);
|
||||
size: .2rem;
|
||||
weight: $font-weight-normal;
|
||||
weight: var(--pgn-typography-font-weight-normal);
|
||||
}
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.donut-chart-number {
|
||||
font: {
|
||||
family: $font-family-monospace;
|
||||
family: var(--pgn-typography-font-family-monospace);
|
||||
size: .5rem;
|
||||
weight: $font-weight-bold;
|
||||
weight: var(--pgn-typography-font-weight-bold);
|
||||
}
|
||||
line-height: 1rem;
|
||||
text-anchor: middle;
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
|
||||
.donut-chart-text {
|
||||
fill: $primary-500;
|
||||
fill: var(--pgn-color-primary-500);
|
||||
-moz-transform: translateY(0.25em);
|
||||
-ms-transform: translateY(0.25em);
|
||||
-webkit-transform: translateY(0.25em);
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
.donut-ring, .donut-segment, .donut-hole {
|
||||
&.complete-stroke {
|
||||
stroke: $info-500;
|
||||
stroke: var(--pgn-color-info-500);
|
||||
}
|
||||
|
||||
&.divider-stroke {
|
||||
@@ -65,10 +65,10 @@
|
||||
}
|
||||
|
||||
&.incomplete-stroke {
|
||||
stroke: $light-300;
|
||||
stroke: var(--pgn-color-light-300);
|
||||
}
|
||||
|
||||
&.locked-stroke {
|
||||
stroke: $primary-500;
|
||||
stroke: var(--pgn-color-primary-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,26 +8,57 @@ import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import GradeRangeTooltip from './GradeRangeTooltip';
|
||||
import messages from '../messages';
|
||||
import { getLatestDueDateInFuture } from '../../utils';
|
||||
|
||||
const ResponsiveText = ({
|
||||
wideScreen, children, hasLetterGrades, passingGrade,
|
||||
}) => {
|
||||
const className = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
|
||||
const iconSize = wideScreen ? 'h3' : 'h4';
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{children}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName={iconSize} passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const NoticeRow = ({
|
||||
wideScreen, icon, bgClass, message,
|
||||
}) => {
|
||||
const textClass = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
|
||||
return (
|
||||
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${bgClass}`}>
|
||||
<div className="col-auto p-0">{icon}</div>
|
||||
<div className="col-11 pl-2 px-0">
|
||||
<span className={textClass}>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CourseGradeFooter = ({ passingGrade }) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
letterGrade,
|
||||
},
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
assignmentTypeGradeSummary,
|
||||
courseGrade: { isPassing, letterGrade },
|
||||
gradingPolicy: { gradeRange },
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const latestDueDate = getLatestDueDateInFuture(assignmentTypeGradeSummary);
|
||||
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
|
||||
const hasLetterGrades = Object.keys(gradeRange).length > 1;
|
||||
|
||||
const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key
|
||||
// build footer text
|
||||
let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade });
|
||||
|
||||
if (isPassing) {
|
||||
if (hasLetterGrades) {
|
||||
const minGradeRangeCutoff = gradeRange[letterGrade] * 100;
|
||||
@@ -47,42 +78,63 @@ const CourseGradeFooter = ({ passingGrade }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const icon = isPassing ? <Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
|
||||
: <Icon src={WarningFilled} className="d-inline-flex align-bottom" />;
|
||||
const passingIcon = isPassing ? (
|
||||
<Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
|
||||
) : (
|
||||
<Icon src={WarningFilled} className="d-inline-flex align-bottom" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${isPassing ? 'bg-success-100' : 'bg-warning-100'}`}>
|
||||
<div className="col-auto p-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="col-11 pl-2 px-0">
|
||||
{!wideScreen && (
|
||||
<span className="h5 align-bottom">
|
||||
<div>
|
||||
<NoticeRow
|
||||
wideScreen={wideScreen}
|
||||
icon={passingIcon}
|
||||
bgClass={isPassing ? 'bg-success-100' : 'bg-warning-100'}
|
||||
message={(
|
||||
<ResponsiveText
|
||||
wideScreen={wideScreen}
|
||||
hasLetterGrades={hasLetterGrades}
|
||||
passingGrade={passingGrade}
|
||||
>
|
||||
{footerText}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName="h4" passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</ResponsiveText>
|
||||
)}
|
||||
{wideScreen && (
|
||||
<span className="h4 m-0 align-bottom">
|
||||
{footerText}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName="h3" passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
{latestDueDate && (
|
||||
<NoticeRow
|
||||
wideScreen={wideScreen}
|
||||
icon={<Icon src={WarningFilled} className="d-inline-flex align-bottom" />}
|
||||
bgClass="bg-warning-100"
|
||||
message={intl.formatMessage(messages.courseGradeFooterDueDateNotice, {
|
||||
dueDate: intl.formatDate(latestDueDate, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short',
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResponsiveText.propTypes = {
|
||||
wideScreen: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
hasLetterGrades: PropTypes.bool.isRequired,
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
NoticeRow.propTypes = {
|
||||
wideScreen: PropTypes.bool.isRequired,
|
||||
icon: PropTypes.element.isRequired,
|
||||
bgClass: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
CourseGradeFooter.propTypes = {
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
assignmentTypeGradeSummary,
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
percent,
|
||||
@@ -25,6 +26,8 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
|
||||
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
|
||||
const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== 'none');
|
||||
|
||||
if (isLocaleRtl) {
|
||||
currentGradeDirection = currentGrade < 50 ? '-' : '';
|
||||
}
|
||||
@@ -56,6 +59,15 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
|
||||
>
|
||||
{intl.formatMessage(messages.currentGradeLabel)}
|
||||
</text>
|
||||
<text
|
||||
className="x-small"
|
||||
textAnchor={currentGrade < 50 ? 'start' : 'end'}
|
||||
x={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`}
|
||||
y="35px"
|
||||
style={{ transform: `translateX(${currentGradeDirection}3.4em)` }}
|
||||
>
|
||||
{hasHiddenGrades ? ` + ${intl.formatMessage(messages.hiddenScoreLabel)}` : ''}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,24 +4,24 @@
|
||||
}
|
||||
|
||||
.grade-bar__base {
|
||||
fill: $light-300;
|
||||
fill: var(--pgn-color-light-300);
|
||||
}
|
||||
|
||||
.grade-bar__divider {
|
||||
fill: $primary-500;
|
||||
fill: var(--pgn-color-primary-500);
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.grade-bar--passing {
|
||||
fill: $primary-500;
|
||||
fill: var(--pgn-color-primary-500);
|
||||
}
|
||||
|
||||
.grade-bar--current-passing {
|
||||
fill: $success-500;
|
||||
fill: var(--pgn-color-success-500);
|
||||
}
|
||||
|
||||
.grade-bar--current-non-passing {
|
||||
fill: $accent-b;
|
||||
fill: var(--pgn-color-accent-b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,22 +31,22 @@
|
||||
|
||||
#minimum-grade-tooltip {
|
||||
.arrow::after {
|
||||
border-bottom-color: $primary-500;
|
||||
border-bottom-color: var(--pgn-color-primary-500);
|
||||
}
|
||||
}
|
||||
|
||||
#passing-grade-tooltip {
|
||||
background: $success-500;
|
||||
background: var(--pgn-color-success-500);
|
||||
|
||||
.arrow::after {
|
||||
border-top-color: $success-500;
|
||||
border-top-color: var(--pgn-color-success-500);
|
||||
}
|
||||
}
|
||||
|
||||
#non-passing-grade-tooltip {
|
||||
background: $accent-b;
|
||||
background: var(--pgn-color-accent-b);
|
||||
|
||||
.arrow::after {
|
||||
border-top-color: $accent-b;
|
||||
border-top-color: var(--pgn-color-accent-b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,12 @@ const GradeSummary = () => {
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
gradingPolicy: {
|
||||
assignmentPolicies,
|
||||
},
|
||||
assignmentTypeGradeSummary,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const [allOfSomeAssignmentTypeIsLocked, setAllOfSomeAssignmentTypeIsLocked] = useState(false);
|
||||
|
||||
if (assignmentPolicies.length === 0) {
|
||||
if (assignmentTypeGradeSummary.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { DataTable } from '@openedx/paragon';
|
||||
import { Lock } from '@openedx/paragon/icons';
|
||||
import { useContextId } from '../../../../data/hooks';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
@@ -16,9 +17,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
gradingPolicy: {
|
||||
assignmentPolicies,
|
||||
},
|
||||
assignmentTypeGradeSummary,
|
||||
gradesFeatureIsFullyLocked,
|
||||
sectionScores,
|
||||
} = useModel('progress', courseId);
|
||||
@@ -55,7 +54,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const gradeSummaryData = assignmentPolicies.map((assignment) => {
|
||||
const gradeSummaryData = assignmentTypeGradeSummary.map((assignment) => {
|
||||
const {
|
||||
averageGrade,
|
||||
numDroppable,
|
||||
@@ -80,13 +79,24 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
|
||||
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignmentType);
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
|
||||
let weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`;
|
||||
let gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`;
|
||||
|
||||
if (assignment.hasHiddenContribution === 'all') {
|
||||
gradeDisplay = <Lock data-testid="lock-icon" />;
|
||||
weightedGradeDisplay = <Lock data-testid="lock-icon" />;
|
||||
} else if (assignment.hasHiddenContribution === 'some') {
|
||||
gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`;
|
||||
weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: {
|
||||
footnoteId, footnoteMarker, type: assignmentType, locked,
|
||||
},
|
||||
weight: { weight: `${(weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
grade: { grade: `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
weightedGrade: { weightedGrade: `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
grade: { grade: gradeDisplay, locked },
|
||||
weightedGrade: { weightedGrade: weightedGradeDisplay, locked },
|
||||
};
|
||||
});
|
||||
const getAssignmentTypeCell = (value) => (
|
||||
@@ -102,6 +112,16 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="micro mb-3 pl-3 text-gray-700">
|
||||
<li>
|
||||
<b>{intl.formatMessage(messages.hiddenScoreLabel)}: </b>
|
||||
{intl.formatMessage(messages.hiddenScoreInfoText)}
|
||||
</li>
|
||||
<li>
|
||||
<b><Lock style={{ height: '15px' }} />: </b>
|
||||
{` ${intl.formatMessage(messages.hiddenScoreLockInfoText)}`}
|
||||
</li>
|
||||
</ul>
|
||||
<DataTable
|
||||
data={gradeSummaryData}
|
||||
itemCount={gradeSummaryData.length}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
DataTable,
|
||||
DataTableContext,
|
||||
Icon,
|
||||
OverlayTrigger,
|
||||
Stack,
|
||||
@@ -17,18 +14,6 @@ import messages from '../messages';
|
||||
|
||||
const GradeSummaryTableFooter = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { data } = useContext(DataTableContext);
|
||||
|
||||
const rawGrade = data.reduce(
|
||||
(grade, currentValue) => {
|
||||
const { weightedGrade } = currentValue.weightedGrade;
|
||||
const percent = weightedGrade.replace(/%/g, '').trim();
|
||||
return grade + parseFloat(percent);
|
||||
},
|
||||
0,
|
||||
).toFixed(2);
|
||||
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
@@ -36,8 +21,16 @@ const GradeSummaryTableFooter = () => {
|
||||
isPassing,
|
||||
percent,
|
||||
},
|
||||
finalGrades,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const getGradePercent = (grade) => {
|
||||
const percentage = grade * 100;
|
||||
return Number.isInteger(percentage) ? percentage.toFixed(0) : percentage.toFixed(2);
|
||||
};
|
||||
|
||||
const rawGrade = getGradePercent(finalGrades);
|
||||
|
||||
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
|
||||
const totalGrade = (percent * 100).toFixed(0);
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.',
|
||||
description: 'Alt text for the grade chart bar',
|
||||
},
|
||||
courseGradeFooterDueDateNotice: {
|
||||
id: 'progress.courseGrade.footer.dueDateNotice',
|
||||
defaultMessage: 'Some assignment scores are not yet included in your total grade. These grades will be released by {dueDate}.',
|
||||
description: 'This is shown when there are pending assignments with a due date in the future',
|
||||
},
|
||||
courseGradeFooterGenericPassing: {
|
||||
id: 'progress.courseGrade.footer.generic.passing',
|
||||
defaultMessage: 'You’re currently passing this course',
|
||||
@@ -148,6 +153,21 @@ const messages = defineMessages({
|
||||
+ "Your weighted grade is what's used to determine if you pass the course.",
|
||||
description: 'The content of (tip box) for the grade summary section',
|
||||
},
|
||||
hiddenScoreLabel: {
|
||||
id: 'progress.hiddenScoreLabel',
|
||||
defaultMessage: 'Hidden Scores',
|
||||
description: 'Text to indicate that some scores are hidden',
|
||||
},
|
||||
hiddenScoreInfoText: {
|
||||
id: 'progress.hiddenScoreInfoText',
|
||||
defaultMessage: 'Scores from assignments that count toward your final grade but some are not shown here.',
|
||||
description: 'Information text about hidden score label',
|
||||
},
|
||||
hiddenScoreLockInfoText: {
|
||||
id: 'progress.hiddenScoreLockInfoText',
|
||||
defaultMessage: 'Scores for an assignment type are hidden but still counted toward the course grade.',
|
||||
description: 'Information text about hidden score label when learners have limited access to grades feature',
|
||||
},
|
||||
noAccessToAssignmentType: {
|
||||
id: 'progress.noAcessToAssignmentType',
|
||||
defaultMessage: 'You do not have access to assignments of type {assignmentType}',
|
||||
|
||||
12
src/course-home/progress-tab/hooks.jsx
Normal file
12
src/course-home/progress-tab/hooks.jsx
Normal 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]);
|
||||
}
|
||||
168
src/course-home/progress-tab/hooks.test.jsx
Normal file
168
src/course-home/progress-tab/hooks.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -5,3 +5,15 @@ export const showUngradedAssignments = () => (
|
||||
getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === 'true'
|
||||
|| getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true
|
||||
);
|
||||
|
||||
export const getLatestDueDateInFuture = (assignmentTypeGradeSummary) => {
|
||||
let latest = null;
|
||||
assignmentTypeGradeSummary.forEach((assignment) => {
|
||||
const assignmentLastGradePublishDate = assignment.lastGradePublishDate;
|
||||
if (assignmentLastGradePublishDate && (!latest || new Date(assignmentLastGradePublishDate) > new Date(latest))
|
||||
&& new Date(assignmentLastGradePublishDate) > new Date()) {
|
||||
latest = assignmentLastGradePublishDate;
|
||||
}
|
||||
});
|
||||
return latest;
|
||||
};
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
.nav a,
|
||||
.nav button {
|
||||
&:hover {
|
||||
background-color: $light-400;
|
||||
background-color: var(--pgn-color-light-400);
|
||||
}
|
||||
}
|
||||
|
||||
.nav a {
|
||||
&:not(.active):hover {
|
||||
background-color: $light-400;
|
||||
background-color: var(--pgn-color-light-400);
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import { AlertList } from '@src/generic/user-messages';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { getCoursewareOutlineSidebarSettings } from '../data/selectors';
|
||||
import Chat from './chat/Chat';
|
||||
import { LearnerToolsSlot } from '../../plugin-slots/LearnerToolsSlot';
|
||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
|
||||
import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot';
|
||||
@@ -62,7 +62,7 @@ const Course = ({
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
||||
);
|
||||
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
|
||||
const shouldDisplayLearnerTools = windowWidth >= breakpoints.medium.minWidth;
|
||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,17 +95,13 @@ const Course = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shouldDisplayChat && (
|
||||
<>
|
||||
<Chat
|
||||
enabled={course.learningAssistantEnabled}
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={course.showCalculator || course.notes.enabled}
|
||||
unitId={unitId}
|
||||
/>
|
||||
</>
|
||||
{shouldDisplayLearnerTools && (
|
||||
<LearnerToolsSlot
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
unitId={unitId}
|
||||
/>
|
||||
)}
|
||||
<div className="w-100 d-flex align-items-center">
|
||||
<CourseOutlineMobileSidebarTriggerSlot />
|
||||
|
||||
@@ -13,17 +13,25 @@ import Course from './Course';
|
||||
import setupDiscussionSidebar from './test-utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
|
||||
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
|
||||
checkExamEntry: () => jest.fn(),
|
||||
}));
|
||||
const mockChatTestId = 'fake-chat';
|
||||
jest.mock('@edx/frontend-lib-special-exams', () => {
|
||||
const actual = jest.requireActual('@edx/frontend-lib-special-exams');
|
||||
return {
|
||||
...actual,
|
||||
__esModule: true,
|
||||
// Mock the default export (SequenceExamWrapper) to just render children
|
||||
// eslint-disable-next-line react/prop-types
|
||||
default: ({ children }) => <div data-testid="sequence-exam-wrapper">{children}</div>,
|
||||
};
|
||||
});
|
||||
const mockLearnerToolsTestId = 'fake-learner-tools';
|
||||
jest.mock(
|
||||
'./chat/Chat',
|
||||
// eslint-disable-next-line react/prop-types
|
||||
() => function ({ courseId }) {
|
||||
return <div className="fake-chat" data-testid={mockChatTestId}>Chat contents {courseId} </div>;
|
||||
},
|
||||
'../../plugin-slots/LearnerToolsSlot',
|
||||
() => ({
|
||||
// eslint-disable-next-line react/prop-types
|
||||
LearnerToolsSlot({ courseId }) {
|
||||
return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const recordFirstSectionCelebration = jest.fn();
|
||||
@@ -360,28 +368,27 @@ describe('Course', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('displays chat when screen is wide enough (browser)', async () => {
|
||||
it('displays learner tools when screen is wide enough (browser)', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
learning_assistant_enabled: true,
|
||||
enrollment: { mode: 'verified' },
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
const { courseware } = testStore.getState();
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
waitFor(() => expect(chat).toBeInTheDocument());
|
||||
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
|
||||
await waitFor(() => expect(learnerTools).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not display chat when screen is too narrow (mobile)', async () => {
|
||||
it('does not display learner tools when screen is too narrow (mobile)', async () => {
|
||||
global.innerWidth = breakpoints.extraSmall.minWidth;
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
learning_assistant_enabled: true,
|
||||
enrollment: { mode: 'verified' },
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
@@ -393,7 +400,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
await expect(chat).not.toBeInTheDocument();
|
||||
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
|
||||
await expect(learnerTools).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
@extend .btn-primary;
|
||||
font-size: 1.2rem;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Chat';
|
||||
@@ -149,7 +149,7 @@ const Calculator = () => {
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<table className="table small">
|
||||
<table className="pgn__data-table small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
|
||||
@@ -4,4 +4,19 @@
|
||||
background-color: #f1f1f1;
|
||||
box-shadow: 0 -1px 0 0 #ddd;
|
||||
}
|
||||
|
||||
table {
|
||||
tr {
|
||||
border-bottom: var(--pgn-size-border-width) solid var(--pgn-color-border);
|
||||
}
|
||||
|
||||
thead tr {
|
||||
border-bottom: calc(2 * var(--pgn-size-border-width)) solid var(--pgn-color-border);
|
||||
border-top: var(--pgn-size-border-width) solid var(--pgn-color-border);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: #f1f1f1;
|
||||
border: solid 1px #ddd;
|
||||
background-color: #f1f1f1 !important;
|
||||
border: solid 1px #ddd !important;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: .3rem;
|
||||
border-top-right-radius: .3rem;
|
||||
|
||||
@@ -18,8 +18,8 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import CelebrationMobile from './assets/celebration_456x328.gif';
|
||||
import CelebrationDesktop from './assets/celebration_750x540.gif';
|
||||
import certificate from '../../../generic/assets/openedx_certificate.png';
|
||||
import certificateLocked from '../../../generic/assets/openedx_locked_certificate.png';
|
||||
import certificate from '../../../generic/assets/edX_certificate.png';
|
||||
import certificateLocked from '../../../generic/assets/edX_locked_certificate.png';
|
||||
import { FormattedPricing } from '../../../generic/upgrade-button';
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { StrictDict } from '@edx/react-unit-test-utils';
|
||||
import { ModalDialog, Modal } from '@openedx/paragon';
|
||||
import { ModalDialog } from '@openedx/paragon';
|
||||
import { ContentIFrameLoaderSlot } from '../../../../plugin-slots/ContentIFrameLoaderSlot';
|
||||
import { ContentIFrameErrorSlot } from '../../../../plugin-slots/ContentIFrameErrorSlot';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
@@ -22,10 +20,10 @@ export const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *; autoplay *'
|
||||
);
|
||||
|
||||
export const testIDs = StrictDict({
|
||||
export const testIDs = {
|
||||
contentIFrame: 'content-iframe-test-id',
|
||||
modalIFrame: 'modal-iframe-test-id',
|
||||
});
|
||||
};
|
||||
|
||||
const ContentIFrame = ({
|
||||
iframeUrl,
|
||||
@@ -65,54 +63,44 @@ const ContentIFrame = ({
|
||||
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 (
|
||||
<>
|
||||
{(shouldShowContent && !hasLoaded) && (
|
||||
showError ? <ErrorPage /> : <ContentIFrameLoaderSlot courseId={courseId} loadingMessage={loadingMessage} />
|
||||
showError ? (
|
||||
<ContentIFrameErrorSlot courseId={courseId} />
|
||||
) : (
|
||||
<ContentIFrameLoaderSlot courseId={courseId} loadingMessage={loadingMessage} />
|
||||
)
|
||||
)}
|
||||
{shouldShowContent && (
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
|
||||
</div>
|
||||
)}
|
||||
{modalOptions.isOpen && (modalOptions.isFullscreen
|
||||
? (
|
||||
{modalOptions.isOpen
|
||||
&& (
|
||||
<ModalDialog
|
||||
dialogClassName="modal-lti"
|
||||
className="modal-lti"
|
||||
onClose={handleModalClose}
|
||||
size="fullscreen"
|
||||
size={modalOptions.isFullscreen ? 'fullscreen' : 'md'}
|
||||
isOpen
|
||||
hasCloseButton={false}
|
||||
>
|
||||
<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>
|
||||
|
||||
) : (
|
||||
<Modal
|
||||
body={modalContent}
|
||||
dialogClassName="modal-lti"
|
||||
onClose={handleModalClose}
|
||||
open
|
||||
/>
|
||||
)
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 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')
|
||||
.mockComponents({
|
||||
Modal: 'Modal',
|
||||
ModalDialog: {
|
||||
Body: 'ModalDialog.Body',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@src/generic/PageLoading', () => 'PageLoading');
|
||||
jest.mock('@src/generic/PageLoading', () => jest.fn(() => <div>PageLoading</div>));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useIFrameBehavior: jest.fn(),
|
||||
@@ -68,14 +53,13 @@ const props = {
|
||||
title: 'test-title',
|
||||
};
|
||||
|
||||
let el;
|
||||
describe('ContentIFrame Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />);
|
||||
});
|
||||
it('initializes iframe behavior hook', () => {
|
||||
expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({
|
||||
@@ -90,61 +74,61 @@ describe('ContentIFrame Component', () => {
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
let component;
|
||||
describe('if shouldShowContent', () => {
|
||||
describe('if not hasLoaded', () => {
|
||||
it('displays errorPage if showError', () => {
|
||||
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
expect(el.instance.findByType(ErrorPage).length).toEqual(1);
|
||||
render(<ContentIFrame {...props} />);
|
||||
const errorPage = screen.getByText('ErrorPage');
|
||||
expect(errorPage).toBeInTheDocument();
|
||||
});
|
||||
it('displays PageLoading component if not showError', () => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByType(ContentIFrameLoaderSlot);
|
||||
expect(component.props.loadingMessage).toEqual(props.loadingMessage);
|
||||
render(<ContentIFrame {...props} />);
|
||||
const pageLoading = screen.getByText('PageLoading');
|
||||
expect(pageLoading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('hasLoaded', () => {
|
||||
it('does not display PageLoading or ErrorPage', () => {
|
||||
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true });
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
expect(el.instance.findByType(PageLoading).length).toEqual(0);
|
||||
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
|
||||
render(<ContentIFrame {...props} />);
|
||||
const pageLoading = screen.queryByText('PageLoading');
|
||||
expect(pageLoading).toBeNull();
|
||||
const errorPage = screen.queryByText('ErrorPage');
|
||||
expect(errorPage).toBeNull();
|
||||
});
|
||||
});
|
||||
it('display iframe with props from hooks', () => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByTestId(testIDs.contentIFrame);
|
||||
expect(component.props).toEqual({
|
||||
allow: IFRAME_FEATURE_POLICY,
|
||||
allowFullScreen: true,
|
||||
scrolling: 'no',
|
||||
referrerPolicy: 'origin',
|
||||
title: props.title,
|
||||
id: props.elementId,
|
||||
src: props.iframeUrl,
|
||||
height: iframeBehavior.iframeHeight,
|
||||
onLoad: iframeBehavior.handleIFrameLoad,
|
||||
'data-testid': testIDs.contentIFrame,
|
||||
});
|
||||
render(<ContentIFrame {...props} />);
|
||||
const iframe = screen.getByTitle(props.title);
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe).toHaveAttribute('id', props.elementId);
|
||||
expect(iframe).toHaveAttribute('src', props.iframeUrl);
|
||||
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
|
||||
expect(iframe).toHaveAttribute('allowfullscreen', '');
|
||||
expect(iframe).toHaveAttribute('scrolling', 'no');
|
||||
expect(iframe).toHaveAttribute('referrerpolicy', 'origin');
|
||||
});
|
||||
});
|
||||
describe('if not shouldShowContent', () => {
|
||||
it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => {
|
||||
el = shallow(<ContentIFrame {...{ ...props, shouldShowContent: false }} />);
|
||||
expect(el.instance.findByType(PageLoading).length).toEqual(0);
|
||||
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
|
||||
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0);
|
||||
render(<ContentIFrame {...{ ...props, shouldShowContent: false }} />);
|
||||
expect(screen.queryByText('PageLoading')).toBeNull();
|
||||
expect(screen.queryByText('ErrorPage')).toBeNull();
|
||||
expect(screen.queryByTitle(props.title)).toBeNull();
|
||||
});
|
||||
});
|
||||
it('does not display modal if modalOptions returns isOpen: false', () => {
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
expect(el.instance.findByType(Modal).length).toEqual(0);
|
||||
render(<ContentIFrame {...props} />);
|
||||
const modal = screen.queryByRole('dialog');
|
||||
expect(modal).toBeNull();
|
||||
});
|
||||
describe('if modalOptions.isOpen', () => {
|
||||
const testModalOpenAndHandleClose = () => {
|
||||
test('Modal component isOpen, with handleModalClose from hook', () => {
|
||||
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
|
||||
it('closes modal on close button click', () => {
|
||||
const closeButton = screen.getByTestId('modal-backdrop');
|
||||
closeButton.click();
|
||||
expect(modalIFrameData.handleModalClose).toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
describe('fullscreen modal', () => {
|
||||
@@ -154,14 +138,13 @@ describe('ContentIFrame Component', () => {
|
||||
...modalIFrameData,
|
||||
modalOptions: { ...modalOptions.withBody, isFullscreen: true },
|
||||
});
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByType(ModalDialog);
|
||||
render(<ContentIFrame {...props} />);
|
||||
});
|
||||
it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
|
||||
const content = component.findByType(ModalDialog.Body)[0].children[0];
|
||||
expect(content.matches(shallow(
|
||||
<div className="unit-modal">{modalOptions.withBody.body}</div>,
|
||||
))).toEqual(true);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
const modalBody = screen.getByText(modalOptions.withBody.body);
|
||||
expect(modalBody).toBeInTheDocument();
|
||||
});
|
||||
testModalOpenAndHandleClose();
|
||||
});
|
||||
@@ -172,53 +155,42 @@ describe('ContentIFrame Component', () => {
|
||||
...modalIFrameData,
|
||||
modalOptions: { ...modalOptions.withUrl, isFullscreen: true },
|
||||
});
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByType(ModalDialog);
|
||||
render(<ContentIFrame {...props} />);
|
||||
});
|
||||
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();
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody });
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByType(Modal);
|
||||
render(<ContentIFrame {...props} />);
|
||||
});
|
||||
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();
|
||||
});
|
||||
describe('url modal', () => {
|
||||
beforeEach(() => {
|
||||
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withUrl });
|
||||
el = shallow(<ContentIFrame {...props} />);
|
||||
[component] = el.instance.findByType(Modal);
|
||||
render(<ContentIFrame {...props} />);
|
||||
});
|
||||
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();
|
||||
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 }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
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 { modelKeys } from './constants';
|
||||
|
||||
import UnitSuspense from './UnitSuspense';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
defineMessages: m => m,
|
||||
useIntl: () => ({ formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage }),
|
||||
}));
|
||||
|
||||
jest.mock('react', () => ({
|
||||
@@ -24,10 +17,9 @@ jest.mock('react', () => ({
|
||||
Suspense: 'Suspense',
|
||||
}));
|
||||
|
||||
jest.mock('../honor-code', () => 'HonorCode');
|
||||
jest.mock('../lock-paywall', () => 'LockPaywall');
|
||||
jest.mock('../honor-code', () => jest.fn(() => <div>HonorCode</div>));
|
||||
jest.mock('../lock-paywall', () => jest.fn(() => <div>LockPaywall</div>));
|
||||
jest.mock('@src/generic/model-store', () => ({ useModel: jest.fn() }));
|
||||
jest.mock('@src/generic/PageLoading', () => 'PageLoading');
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useShouldDisplayHonorCode: jest.fn(() => false),
|
||||
@@ -46,7 +38,6 @@ const props = {
|
||||
id: 'test-id',
|
||||
};
|
||||
|
||||
let el;
|
||||
describe('UnitSuspense component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -54,7 +45,7 @@ describe('UnitSuspense component', () => {
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes models', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
|
||||
const { calls } = useModel.mock;
|
||||
const [unitCall] = calls.filter(call => call[0] === modelKeys.units);
|
||||
const [metaCall] = calls.filter(call => call[0] === modelKeys.coursewareMeta);
|
||||
@@ -66,8 +57,9 @@ describe('UnitSuspense component', () => {
|
||||
describe('LockPaywall', () => {
|
||||
const testNoPaywall = () => {
|
||||
it('does not display LockPaywall', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
expect(el.instance.findByType(LockPaywall).length).toEqual(0);
|
||||
render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
|
||||
const lockPaywall = screen.queryByText('LockPaywall');
|
||||
expect(lockPaywall).toBeNull();
|
||||
});
|
||||
};
|
||||
describe('gating not enabled', () => { testNoPaywall(); });
|
||||
@@ -78,29 +70,29 @@ describe('UnitSuspense component', () => {
|
||||
describe('gating enabled, gated content included', () => {
|
||||
beforeEach(() => { mockModels(true, true); });
|
||||
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
const [component] = el.instance.findByType(GatedUnitContentMessageSlot);
|
||||
expect(component.parent.type).toEqual('Suspense');
|
||||
expect(component.parent.props.fallback)
|
||||
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />);
|
||||
expect(component.props.courseId).toEqual(props.courseId);
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false);
|
||||
render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
|
||||
const lockPaywall = screen.getByText('LockPaywall');
|
||||
expect(lockPaywall).toBeInTheDocument();
|
||||
const suspenseWrapper = lockPaywall.closest('suspense');
|
||||
expect(suspenseWrapper).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('HonorCode', () => {
|
||||
it('does not display HonorCode if useShouldDisplayHonorCode => false', () => {
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false);
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
expect(el.instance.findByType(HonorCode).length).toEqual(0);
|
||||
render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
|
||||
const honorCode = screen.queryByText('HonorCode');
|
||||
expect(honorCode).toBeNull();
|
||||
});
|
||||
it('displays HonorCode component in Suspense wrapper with PageLoading fallback if shouldDisplayHonorCode', () => {
|
||||
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
const [component] = el.instance.findByType(HonorCode);
|
||||
expect(component.parent.type).toEqual('Suspense');
|
||||
expect(component.parent.props.fallback)
|
||||
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />);
|
||||
expect(component.props.courseId).toEqual(props.courseId);
|
||||
render(<IntlProvider locale="en"><UnitSuspense {...props} /></IntlProvider>);
|
||||
const honorCode = screen.getByText('HonorCode');
|
||||
expect(honorCode).toBeInTheDocument();
|
||||
const suspenseWrapper = honorCode.closest('suspense');
|
||||
expect(suspenseWrapper).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { StrictDict } from '@edx/react-unit-test-utils/dist';
|
||||
|
||||
export const modelKeys = StrictDict({
|
||||
export const modelKeys = {
|
||||
units: 'units',
|
||||
coursewareMeta: 'coursewareMeta',
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const views = StrictDict({
|
||||
export const views = {
|
||||
student: 'student_view',
|
||||
public: 'public_view',
|
||||
});
|
||||
} as const;
|
||||
|
||||
export const loadingState = 'loading';
|
||||
|
||||
export const messageTypes = StrictDict({
|
||||
export const messageTypes = {
|
||||
modal: 'plugin.modal',
|
||||
resize: 'plugin.resize',
|
||||
videoFullScreen: 'plugin.videoFullScreen',
|
||||
});
|
||||
autoAdvance: 'plugin.autoAdvance',
|
||||
} as const;
|
||||
|
||||
export default StrictDict({
|
||||
export default {
|
||||
modelKeys,
|
||||
views,
|
||||
loadingState,
|
||||
messageTypes,
|
||||
});
|
||||
};
|
||||
@@ -1,19 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
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';
|
||||
|
||||
export const stateKeys = StrictDict({
|
||||
accessToken: 'accessToken',
|
||||
blockAccess: 'blockAccess',
|
||||
});
|
||||
|
||||
const useExamAccess = ({
|
||||
id,
|
||||
}) => {
|
||||
const isExam = useIsExam();
|
||||
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam);
|
||||
const [blockAccess, setBlockAccess] = React.useState(isExam);
|
||||
|
||||
const fetchExamAccessToken = useFetchExamAccessToken();
|
||||
|
||||
|
||||
@@ -84,6 +84,19 @@ describe('<Unit />', () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { Locked } from '@openedx/paragon/icons';
|
||||
import SidebarContext from '../../sidebar/SidebarContext';
|
||||
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 { UpgradeButton } from '../../../../generic/upgrade-button';
|
||||
import {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
}
|
||||
|
||||
.lock-paywall-container svg {
|
||||
color: $primary-700;
|
||||
color: var(--pgn-color-primary-700);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) and (max-width: 1100px) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#course-sidebar {
|
||||
@media (max-width: map-get($grid-breakpoints, "lg")) {
|
||||
@media (--pgn-size-breakpoint-max-width-lg) {
|
||||
overflow-y: scroll;
|
||||
padding: 0 .625rem !important;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
.outline-sidebar {
|
||||
@media (min-width: map-get($grid-breakpoints, "xl")) {
|
||||
@media (--pgn-size-breakpoint-min-width-xl) {
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
@@ -23,12 +23,12 @@
|
||||
}
|
||||
|
||||
.outline-sidebar-heading {
|
||||
font-weight: $font-weight-bold;
|
||||
font-weight: var(--pgn-typography-font-weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.course-sidebar-section {
|
||||
background: $white;
|
||||
background: var(--pgn-color-white);
|
||||
border: 1px solid #d7d3d1;
|
||||
|
||||
button {
|
||||
@@ -52,7 +52,7 @@
|
||||
#outline-sidebar-outline {
|
||||
margin-top: -1px;
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, "xl")) {
|
||||
@media (--pgn-size-breakpoint-min-width-xl) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -62,14 +62,14 @@
|
||||
|
||||
.collapsible-trigger {
|
||||
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")) {
|
||||
padding-left: map-get($spacers, 4);
|
||||
@media (--pgn-size-breakpoint-max-width-sm) {
|
||||
padding-left: var(--pgn-spacing-spacer-4);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $light-500;
|
||||
background-color: var(--pgn-color-light-500);
|
||||
}
|
||||
|
||||
.collapsible-icon {
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
&:last-child .pgn_collapsible {
|
||||
@extend .mb-0;
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,15 +86,15 @@
|
||||
padding: 0;
|
||||
|
||||
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")) {
|
||||
padding-left: map-get($spacers, 4\.5);
|
||||
@media (--pgn-size-breakpoint-max-width-sm) {
|
||||
padding-left: var(--pgn-spacing-spacer-4-5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background-color: $light-500;
|
||||
background-color: var(--pgn-color-light-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.discussions-sidebar-frame {
|
||||
@media (max-width: map-get($grid-breakpoints, "xl")) {
|
||||
@media (--pgn-size-breakpoint-max-width-xl) {
|
||||
max-height: calc(100vh - 65px);
|
||||
}
|
||||
}
|
||||
|
||||
BIN
src/generic/assets/edX_certificate.png
Normal file
BIN
src/generic/assets/edX_certificate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
src/generic/assets/edX_locked_certificate.png
Normal file
BIN
src/generic/assets/edX_locked_certificate.png
Normal file
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 |
@@ -8,6 +8,6 @@
|
||||
}
|
||||
|
||||
.upsell-bullet a {
|
||||
color: $primary-500;
|
||||
color: var(--pgn-color-primary-500);
|
||||
}
|
||||
|
||||
|
||||
@@ -166,11 +166,13 @@ subscribe(APP_INIT_ERROR, (error) => {
|
||||
initialize({
|
||||
handlers: {
|
||||
config: () => {
|
||||
/* istanbul ignore next */
|
||||
mergeConfig({
|
||||
CONTACT_URL: process.env.CONTACT_URL || null,
|
||||
CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null,
|
||||
CREDIT_HELP_LINK_URL: process.env.CREDIT_HELP_LINK_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_URL: process.env.ENTERPRISE_LEARNER_PORTAL_URL || null,
|
||||
ENABLE_JUMPNAV: process.env.ENABLE_JUMPNAV || null,
|
||||
@@ -194,6 +196,7 @@ initialize({
|
||||
PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL || null,
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS: process.env.SHOW_UNGRADED_ASSIGNMENT_PROGRESS || false,
|
||||
ENABLE_XPERT_AUDIT: process.env.ENABLE_XPERT_AUDIT || false,
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT: process.env.FEATURE_ENABLE_CHAT_V2_ENDPOINT || false,
|
||||
}, 'LearnerAppConfig');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@openedx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
|
||||
|
||||
@import "~@edx/frontend-component-footer/dist/footer";
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
@@ -51,7 +48,7 @@
|
||||
.nav-link {
|
||||
border-bottom: 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
|
||||
border-left: 0;
|
||||
@@ -61,9 +58,9 @@
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
font-weight: $font-weight-normal;
|
||||
color: $primary-500;
|
||||
border-bottom-color: $primary-500;
|
||||
font-weight: var(--pgn-typography-font-weight-normal);
|
||||
color: var(--pgn-color-primary-500);
|
||||
border-bottom-color: var(--pgn-color-primary-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +79,7 @@
|
||||
}
|
||||
|
||||
.sequence {
|
||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-min-width-sm) {
|
||||
border: solid 1px #eaeaea;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -94,7 +91,7 @@
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
@media (max-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-max-width-xs) {
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
@@ -103,15 +100,15 @@
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-max-width-xs) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-min-width-sm) {
|
||||
margin: -1px -1px 0;
|
||||
}
|
||||
|
||||
@media (max-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-max-width-xs) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
@@ -127,13 +124,13 @@
|
||||
height: 3rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: $gray-500;
|
||||
color: var(--pgn-color-gray-500);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
color: $gray-700;
|
||||
color: var(--pgn-color-gray-700);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@@ -148,13 +145,13 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: $primary;
|
||||
background: var(--pgn-color-primary-base);
|
||||
}
|
||||
}
|
||||
|
||||
&.complete {
|
||||
background-color: #eef7e5;
|
||||
color: $success;
|
||||
color: var(--pgn-color-success-base);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
@@ -218,12 +215,12 @@
|
||||
min-width: 0;
|
||||
margin: 0 1rem;
|
||||
text-overflow: ellipsis;
|
||||
color: $gray-700;
|
||||
color: var(--pgn-color-gray-700);
|
||||
}
|
||||
|
||||
&.active {
|
||||
.unit-icon {
|
||||
color: $primary-500;
|
||||
color: var(--pgn-color-primary-500);
|
||||
}
|
||||
|
||||
&:after {
|
||||
@@ -235,7 +232,7 @@
|
||||
right: auto;
|
||||
width: 2px;
|
||||
height: auto;
|
||||
background: $primary;
|
||||
background: var(--pgn-color-primary-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,18 +247,18 @@
|
||||
|
||||
.previous-btn,
|
||||
.next-btn {
|
||||
border: 1px solid $light-400 !important;
|
||||
color: $gray-700;
|
||||
border: 1px solid var(--pgn-color-light-400) !important;
|
||||
color: var(--pgn-color-gray-700);
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-max-width-sm) {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-min-width-sm) {
|
||||
min-width: fit-content;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
@@ -272,7 +269,7 @@
|
||||
border-left-width: 0;
|
||||
margin-left: 0;
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-min-width-sm) {
|
||||
border-left-width: 1px;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
@@ -282,7 +279,7 @@
|
||||
border-left-width: 1px;
|
||||
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-right-width: 1px;
|
||||
}
|
||||
@@ -296,15 +293,20 @@
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||
padding-left: $grid-gutter-width;
|
||||
padding-right: $grid-gutter-width;
|
||||
@media (--pgn-size-breakpoint-min-width-sm) {
|
||||
padding-left: var(--pgn-spacing-grid-gutter-width);
|
||||
padding-right: var(--pgn-spacing-grid-gutter-width);
|
||||
}
|
||||
|
||||
@media (min-width: 830px) {
|
||||
padding-left: 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 {
|
||||
@@ -316,8 +318,8 @@
|
||||
// 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
|
||||
// iframe.
|
||||
margin-left: -$grid-gutter-width * .5;
|
||||
margin-right: -$grid-gutter-width * .5;
|
||||
margin-left: calc(var(--pgn-spacing-grid-gutter-width) * -0.5);
|
||||
margin-right: calc(var(--pgn-spacing-grid-gutter-width) * -0.5);
|
||||
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -339,9 +341,9 @@
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: map-get($grid-breakpoints, "sm")) {
|
||||
@media (--pgn-size-breakpoint-max-width-xs) {
|
||||
flex-direction: column;
|
||||
gap: $spacer;
|
||||
gap: var(--pgn-spacing-spacer-base);
|
||||
}
|
||||
|
||||
.previous-button,
|
||||
@@ -370,19 +372,22 @@
|
||||
// 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.
|
||||
.modal-lti {
|
||||
height: 100%;
|
||||
height: 80vh;
|
||||
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
|
||||
// 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.
|
||||
> 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
|
||||
// set modal-content's height as well to get the modal to expand to full-screen height.
|
||||
.modal-content {
|
||||
height: 80vh;
|
||||
}
|
||||
.pgn__modal-body-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -411,8 +416,8 @@
|
||||
|
||||
.icon-hover {
|
||||
&:hover {
|
||||
color: $primary-500 !important;
|
||||
background-color: $light-300 !important;
|
||||
color: var(--pgn-color-primary-500) !important;
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +440,7 @@
|
||||
height: 56px !important;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
@media (--pgn-size-breakpoint-max-width-xs) {
|
||||
.course-outline-tab .pgn__card {
|
||||
.pgn__card-header {
|
||||
display: block;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Input } from '@openedx/paragon';
|
||||
import { Form } from '@openedx/paragon';
|
||||
|
||||
import { MasqueradeStatus, Payload } from './data/api';
|
||||
import messages from './messages';
|
||||
@@ -40,11 +40,10 @@ export const MasqueradeUserNameInput: React.FC<Props> = ({ onSubmit, onError, ..
|
||||
}, [handleSubmit]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
<Form.Control
|
||||
aria-labelledby="masquerade-search-label"
|
||||
label={intl.formatMessage(messages.userNameLabel)}
|
||||
onKeyPress={handleKeyPress}
|
||||
type="text"
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
||||
39
src/plugin-slots/ContentIFrameErrorSlot/README.md
Normal file
39
src/plugin-slots/ContentIFrameErrorSlot/README.md
Normal 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;
|
||||
```
|
||||
17
src/plugin-slots/ContentIFrameErrorSlot/index.tsx
Normal file
17
src/plugin-slots/ContentIFrameErrorSlot/index.tsx
Normal 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>
|
||||
);
|
||||
28
src/plugin-slots/LearnerToolsSlot/README.md
Normal file
28
src/plugin-slots/LearnerToolsSlot/README.md
Normal 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
|
||||
47
src/plugin-slots/LearnerToolsSlot/index.jsx
Normal file
47
src/plugin-slots/LearnerToolsSlot/index.jsx
Normal 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,
|
||||
};
|
||||
104
src/plugin-slots/LearnerToolsSlot/index.test.jsx
Normal file
104
src/plugin-slots/LearnerToolsSlot/index.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
* [`org.openedx.frontend.learning.course_outline_tab_notifications.v1`](./CourseOutlineTabNotificationsSlot/)
|
||||
* [`org.openedx.frontend.learning.course_recommendations.v1`](./CourseRecommendationsSlot/)
|
||||
* [`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.notification_tray.v1`](./NotificationTraySlot/)
|
||||
* [`org.openedx.frontend.learning.notification_widget.v1`](./NotificationWidgetSlot/)
|
||||
|
||||
@@ -27,7 +27,7 @@ const UnitTitleSlot = ({
|
||||
>
|
||||
<div className="d-flex justify-content-between">
|
||||
<div className="mb-0">
|
||||
<h3 className="h3">{unit.title}</h3>
|
||||
<h1 className="unit-title">{unit.title}</h1>
|
||||
</div>
|
||||
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export const getUnsubscribeUrl = (userToken, updatePatch) => (
|
||||
`${getConfig().LMS_BASE_URL}/api/notifications/preferences/update/${userToken}/${updatePatch}/`
|
||||
export const getUnsubscribeUrl = (userToken) => (
|
||||
`${getConfig().LMS_BASE_URL}/api/notifications/preferences/update/${userToken}/`
|
||||
);
|
||||
|
||||
export async function unsubscribeNotificationPreferences(userToken, updatePatch) {
|
||||
const url = getUnsubscribeUrl(userToken, updatePatch);
|
||||
export async function unsubscribeNotificationPreferences(userToken) {
|
||||
const url = getUnsubscribeUrl(userToken);
|
||||
return getAuthenticatedHttpClient().get(url);
|
||||
}
|
||||
|
||||
@@ -17,18 +17,18 @@ import messages from './messages';
|
||||
|
||||
const PreferencesUnsubscribe = () => {
|
||||
const intl = useIntl();
|
||||
const { userToken, updatePatch } = useParams();
|
||||
const { userToken } = useParams();
|
||||
const [status, setStatus] = useState(LOADING);
|
||||
|
||||
useEffect(() => {
|
||||
unsubscribeNotificationPreferences(userToken, updatePatch).then(
|
||||
unsubscribeNotificationPreferences(userToken).then(
|
||||
() => setStatus(LOADED),
|
||||
(error) => {
|
||||
setStatus(FAILED);
|
||||
logError(error);
|
||||
},
|
||||
);
|
||||
sendTrackEvent('edx.ui.lms.notifications.preferences.unsubscribe', { userToken, updatePatch });
|
||||
sendTrackEvent('edx.ui.lms.notifications.preferences.unsubscribe', { userToken });
|
||||
}, []);
|
||||
|
||||
const pageContent = {
|
||||
|
||||
@@ -24,8 +24,7 @@ describe('Notification Preferences One Click Unsubscribe', () => {
|
||||
let component;
|
||||
let store;
|
||||
const userToken = '1234';
|
||||
const updatePatch = 'abc123';
|
||||
const url = getUnsubscribeUrl(userToken, updatePatch);
|
||||
const url = getUnsubscribeUrl(userToken);
|
||||
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore();
|
||||
@@ -39,7 +38,7 @@ describe('Notification Preferences One Click Unsubscribe', () => {
|
||||
component = (
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<UserMessagesProvider>
|
||||
<MemoryRouter initialEntries={[`${`/preferences-unsubscribe/${userToken}/${updatePatch}/`}`]}>
|
||||
<MemoryRouter initialEntries={[`${`/preferences-unsubscribe/${userToken}/`}`]}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.PREFERENCES_UNSUBSCRIBE} element={<PreferencesUnsubscribe />} />
|
||||
</Routes>
|
||||
@@ -69,7 +68,6 @@ describe('Notification Preferences One Click Unsubscribe', () => {
|
||||
expect(screen.getByTestId('heading-text')).toHaveTextContent('Error unsubscribing from preference');
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.notifications.preferences.unsubscribe', {
|
||||
userToken,
|
||||
updatePatch,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
endCourseHomeTour,
|
||||
endCoursewareTour,
|
||||
fetchTourData,
|
||||
openCourseHomeTour,
|
||||
} from './data';
|
||||
|
||||
const ProductTours = ({
|
||||
@@ -164,7 +165,7 @@ const ProductTours = ({
|
||||
is_staff: administrator,
|
||||
});
|
||||
dispatch(closeNewUserCourseHomeModal());
|
||||
setIsNewUserCourseHomeTourEnabled(true);
|
||||
dispatch(openCourseHomeTour());
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
endCourseHomeTour,
|
||||
endCoursewareTour,
|
||||
fetchTourData,
|
||||
openCourseHomeTour,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
disableCoursewareTour,
|
||||
disableNewUserCourseHomeModal,
|
||||
setTourData,
|
||||
launchCourseHomeTour,
|
||||
} from './slice';
|
||||
|
||||
export function closeNewUserCourseHomeModal() {
|
||||
return async (dispatch) => dispatch(disableNewUserCourseHomeModal());
|
||||
}
|
||||
|
||||
export function openCourseHomeTour() {
|
||||
return async (dispatch) => dispatch(launchCourseHomeTour());
|
||||
}
|
||||
|
||||
export function endCourseHomeTour(username) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[dir="rtl"] .new-user-tour-dialog .pgn__modal-hero .pgn__modal-hero-bg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ export function buildMinimalCourseBlocks(courseId, title, options = {}) {
|
||||
effort_activities: 2,
|
||||
effort_time: 15,
|
||||
type: 'sequential',
|
||||
is_preview: false,
|
||||
},
|
||||
{ courseId },
|
||||
)];
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Lightbulb, MoneyFilled } from '@openedx/paragon/icons';
|
||||
import {
|
||||
@@ -16,7 +15,12 @@ import { useModel } from '../../generic/model-store';
|
||||
import StreakMobileImage from './assets/Streak_mobile.png';
|
||||
import StreakDesktopImage from './assets/Streak_desktop.png';
|
||||
import messages from './messages';
|
||||
import { recordModalClosing, recordStreakCelebration } from './utils';
|
||||
import {
|
||||
calculateVoucherDiscountPercentage,
|
||||
getDiscountCodePercentage,
|
||||
recordModalClosing,
|
||||
recordStreakCelebration,
|
||||
} from './utils';
|
||||
|
||||
function getRandomFactoid(intl, streakLength) {
|
||||
const boldedSectionA = intl.formatMessage(messages.streakFactoidABoldedSection);
|
||||
@@ -42,13 +46,6 @@ function getRandomFactoid(intl, streakLength) {
|
||||
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 }) => (
|
||||
<span>
|
||||
{intl.formatMessage(messages.streakButton)}
|
||||
@@ -83,34 +80,38 @@ const StreakModal = ({
|
||||
|
||||
// Ask ecommerce to calculate discount savings
|
||||
useEffect(() => {
|
||||
if (streakDiscountCouponEnabled && verifiedMode && getConfig().ECOMMERCE_BASE_URL) {
|
||||
calculateVoucherDiscount(discountCode, verifiedMode.sku, username)
|
||||
.then(
|
||||
(result) => {
|
||||
const { totalInclTax, totalInclTaxExclDiscounts } = 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.
|
||||
setDiscountPercent(1 - totalInclTax / totalInclTaxExclDiscounts);
|
||||
sendTrackEvent('edx.bi.course.streak_discount_enabled', {
|
||||
course_id: courseId,
|
||||
sku: verifiedMode.sku,
|
||||
});
|
||||
} else {
|
||||
setDiscountPercent(0);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// ignore any errors - we just won't show the discount to the user then
|
||||
setDiscountPercent(0);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setDiscountPercent(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
(async () => {
|
||||
let streakDiscountPercentage = 0;
|
||||
try {
|
||||
if (streakDiscountCouponEnabled && verifiedMode) {
|
||||
// If the discount service is available, use it to get the discount percentage
|
||||
if (getConfig().DISCOUNT_CODE_INFO_URL) {
|
||||
streakDiscountPercentage = await getDiscountCodePercentage(
|
||||
discountCode,
|
||||
courseId,
|
||||
);
|
||||
// If the discount service is not available, fall back to ecommerce to calculate the discount percentage
|
||||
} else if (getConfig().ECOMMERCE_BASE_URL) {
|
||||
streakDiscountPercentage = await calculateVoucherDiscountPercentage(
|
||||
discountCode,
|
||||
verifiedMode.sku,
|
||||
username,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore any errors - we just won't show the discount to the user then
|
||||
} finally {
|
||||
if (streakDiscountPercentage) {
|
||||
sendTrackEvent('edx.bi.course.streak_discount_enabled', {
|
||||
course_id: courseId,
|
||||
sku: verifiedMode.sku,
|
||||
});
|
||||
}
|
||||
setDiscountPercent(streakDiscountPercentage);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [streakDiscountCouponEnabled, username, verifiedMode]);
|
||||
|
||||
if (!isStreakCelebrationOpen) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
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() {
|
||||
mockData.streakDiscountCouponEnabled = true;
|
||||
axiosMock.onGet(calculateUrl).reply(500);
|
||||
@@ -105,4 +118,22 @@ describe('Loaded Tab Page', () => {
|
||||
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('You’ve 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user