diff --git a/.env b/.env
index d29c3f6c..97e7a576 100644
--- a/.env
+++ b/.env
@@ -45,3 +45,4 @@ NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
# Fallback in local style files
PARAGON_THEME_URLS={}
+ENABLE_PROGRAM_DASHBOARD=false
diff --git a/package-lock.json b/package-lock.json
index 2a8d38c0..15cda327 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -137,6 +137,7 @@
"integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
@@ -2302,6 +2303,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
},
@@ -2324,6 +2326,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
}
@@ -2548,6 +2551,7 @@
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.7.0.tgz",
"integrity": "sha512-gd/8QXEXRWFMHLhy/NOJp2wjumcDh+YiUjZmllhSbhXb988Wxqoz77D30Imt4XJXhzORjmCC7O/8cBlXtT85YQ==",
"license": "AGPL-3.0",
+ "peer": true,
"dependencies": {
"@cospired/i18n-iso-languages": "4.2.0",
"@formatjs/intl-pluralrules": "4.3.3",
@@ -2718,13 +2722,14 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
- "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.1.0",
+ "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
@@ -2732,6 +2737,7 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
"integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2739,9 +2745,10 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
- "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -3221,6 +3228,7 @@
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
@@ -3347,6 +3355,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3369,6 +3378,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3391,6 +3401,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3407,6 +3418,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3423,6 +3435,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3439,6 +3452,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3455,6 +3469,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3471,6 +3486,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3487,6 +3503,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3503,6 +3520,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3519,6 +3537,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3535,6 +3554,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3557,6 +3577,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3579,6 +3600,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3601,6 +3623,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3623,6 +3646,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3645,6 +3669,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3667,6 +3692,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3689,6 +3715,7 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -3708,6 +3735,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3727,6 +3755,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3746,6 +3775,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4286,6 +4316,7 @@
"integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/core": "^7.11.6",
"@jest/types": "^29.6.3",
@@ -4323,6 +4354,7 @@
"integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/schemas": "^29.6.3",
"@types/istanbul-lib-coverage": "^2.0.0",
@@ -4816,6 +4848,7 @@
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4844,6 +4877,7 @@
"version": "2.1.8-no-fsevents.3",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz",
"integrity": "sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==",
+ "dev": true,
"license": "MIT",
"optional": true
},
@@ -4914,6 +4948,7 @@
"integrity": "sha512-pfEIpjVYxofCH4ytuHfFzv426BHO5QTQqgu8hBuVvpxjHVuPV42CxlFVN0eszKqm65yXTqRrhDOJEv5RDoHSaw==",
"devOptional": true,
"license": "AGPL-3.0",
+ "peer": true,
"dependencies": {
"@babel/cli": "^7.24.8",
"@babel/core": "^7.24.9",
@@ -5075,6 +5110,7 @@
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.19.2.tgz",
"integrity": "sha512-4umD73Ujknvo4Bt1dr5X9QvwR1vlSkdoG/s6SaKmBlUa1eP1CicpIBqZiMC5/z3BUPcorxWedXQKp1Rdlv+73Q==",
"license": "Apache-2.0",
+ "peer": true,
"workspaces": [
"example",
"component-generator",
@@ -5649,6 +5685,7 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -5887,6 +5924,7 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -6155,6 +6193,7 @@
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6166,8 +6205,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -6529,6 +6567,7 @@
"integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -6582,6 +6621,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -6592,7 +6632,6 @@
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz",
"integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
@@ -6742,6 +6781,7 @@
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
@@ -6790,6 +6830,7 @@
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"devOptional": true,
"license": "BSD-2-Clause",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -6998,6 +7039,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7011,6 +7053,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7024,6 +7067,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7037,6 +7081,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7050,6 +7095,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7063,6 +7109,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7076,6 +7123,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7089,6 +7137,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7102,6 +7151,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7115,6 +7165,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7128,6 +7179,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7141,6 +7193,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7154,6 +7207,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7167,6 +7221,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7180,6 +7235,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7193,6 +7249,7 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -7209,6 +7266,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7222,6 +7280,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7235,6 +7294,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7508,6 +7568,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7605,6 +7666,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -8188,6 +8250,7 @@
"integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/transform": "^29.7.0",
"@types/babel__core": "^7.1.14",
@@ -8668,6 +8731,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -10313,8 +10377,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/dom-converter": {
"version": "0.2.0",
@@ -10879,6 +10942,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
"license": "BSD-3-Clause",
"optional": true,
"engines": {
@@ -10892,6 +10956,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -10948,6 +11013,7 @@
"integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"eslint-config-airbnb-base": "^15.0.0",
"object.assign": "^4.1.2",
@@ -10990,6 +11056,7 @@
"integrity": "sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"eslint-config-airbnb-base": "^15.0.0"
},
@@ -11392,6 +11459,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -11449,6 +11517,7 @@
"integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"aria-query": "^5.3.2",
"array-includes": "^3.1.8",
@@ -11496,6 +11565,7 @@
"integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"array-includes": "^3.1.8",
"array.prototype.findlast": "^1.2.5",
@@ -11529,6 +11599,7 @@
"integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -12612,6 +12683,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -14680,6 +14752,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -17669,6 +17742,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
@@ -18454,7 +18528,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -18470,7 +18543,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -18513,6 +18585,7 @@
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -18782,6 +18855,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -18951,6 +19025,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -19256,6 +19331,7 @@
"integrity": "sha512-FPvF2XxTSikpJxcr+bHut2H4gJ17+18Uy20D5/F+SKzFap62R3cM5wH6b8WN3LyGSYeQilLEcJcR1fjBSI2S1A==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -19345,6 +19421,7 @@
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
@@ -20167,6 +20244,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -21178,6 +21256,7 @@
"integrity": "sha512-+xU0IA1StzqAqFs/QtXkK+XJa7wpS4X5H+JQccRKsRCElgeLGocFU1U/UMvMUylKFw6vwGV+Y/a2wb2pm5rFFQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@bundled-es-modules/deepmerge": "^4.3.1",
"@bundled-es-modules/glob": "^10.4.2",
@@ -21685,6 +21764,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -21823,6 +21903,7 @@
"integrity": "sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"bs-logger": "0.x",
"fast-json-stable-stringify": "2.x",
@@ -21959,7 +22040,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD"
+ "license": "0BSD",
+ "peer": true
},
"node_modules/tsutils": {
"version": "3.21.0",
@@ -22013,6 +22095,7 @@
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"devOptional": true,
"license": "(MIT OR CC0-1.0)",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -22118,6 +22201,7 @@
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -22266,6 +22350,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -22625,6 +22710,7 @@
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -22733,6 +22819,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1",
@@ -22813,6 +22900,7 @@
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5",
diff --git a/src/App.jsx b/src/App.jsx
index 96195f3b..c49a8d64 100755
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,18 +1,12 @@
import React from 'react';
-import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ErrorPage } from '@edx/frontend-platform/react';
-import { FooterSlot } from '@edx/frontend-component-footer';
import { Alert } from '@openedx/paragon';
import Dashboard from 'containers/Dashboard';
-import AppWrapper from 'containers/AppWrapper';
-import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
-
-import { getConfig } from '@edx/frontend-platform';
import { useInitializeLearnerHome } from 'data/hooks';
import { useMasquerade } from 'data/context';
import messages from './messages';
@@ -26,28 +20,16 @@ export const App = () => {
const supportEmail = data?.platformSettings?.supportEmail || undefined;
return (
- <>
-
- {formatMessage(messages.pageTitle)}
-
-
-
-
-
-
- {hasNetworkFailure
- ? (
-
-
-
- ) : (
-
- )}
-
-
-
-
- >
+
+ {hasNetworkFailure
+ ? (
+
+
+
+ ) : (
+
+ )}
+
);
};
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 900e96d6..dacf66b5 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
@@ -15,12 +15,8 @@ jest.mock('data/context', () => ({
useMasquerade: jest.fn(() => ({ masqueradeUser: null })),
}));
-jest.mock('@edx/frontend-component-footer', () => ({
- FooterSlot: jest.fn(() => FooterSlot
),
-}));
jest.mock('containers/Dashboard', () => jest.fn(() => Dashboard
));
jest.mock('containers/LearnerDashboardHeader', () => jest.fn(() => LearnerDashboardHeader
));
-jest.mock('containers/AppWrapper', () => jest.fn(({ children }) => {children}
));
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({})),
@@ -43,31 +39,12 @@ useInitializeLearnerHome.mockReturnValue({
describe('App router component', () => {
describe('component', () => {
- const runBasicTests = () => {
- it('displays title in helmet component', async () => {
- await waitFor(() => expect(document.title).toEqual(messages.pageTitle.defaultMessage));
- });
- it('displays learner dashboard header', () => {
- const learnerDashboardHeader = screen.getByText('LearnerDashboardHeader');
- expect(learnerDashboardHeader).toBeInTheDocument();
- });
- it('wraps the header and main components in an AppWrapper widget container', () => {
- const appWrapper = screen.getByText('LearnerDashboardHeader').parentElement;
- expect(appWrapper).toHaveClass('AppWrapper');
- expect(appWrapper.children[1].id).toEqual('main');
- });
- it('displays footer slot', () => {
- const footerSlot = screen.getByText('FooterSlot');
- expect(footerSlot).toBeInTheDocument();
- });
- };
describe('no network failure', () => {
beforeEach(() => {
jest.clearAllMocks();
getConfig.mockReturnValue({});
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -79,7 +56,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' });
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -91,7 +67,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' });
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -107,7 +82,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({});
render();
});
- runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
@@ -120,7 +94,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({});
render();
});
- runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
diff --git a/src/config/index.js b/src/config/index.js
index 43b10afc..a06bea4d 100644
--- a/src/config/index.js
+++ b/src/config/index.js
@@ -22,6 +22,7 @@ const configuration = {
ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true',
NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES === 'true',
SHOW_UNENROLL_SURVEY: process.env.SHOW_UNENROLL_SURVEY === 'true',
+ ENABLE_PROGRAM_DASHBOARD: process.env.ENABLE_PROGRAM_DASHBOARD === 'true',
};
const features = {};
diff --git a/src/containers/AppWrapper/index.jsx b/src/containers/AppWrapper/index.jsx
deleted file mode 100644
index 72f4a926..00000000
--- a/src/containers/AppWrapper/index.jsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import PropTypes from 'prop-types';
-
-export const AppWrapper = ({
- children,
-}) => children;
-AppWrapper.propTypes = {
- children: PropTypes.oneOfType([
- PropTypes.node,
- PropTypes.arrayOf(PropTypes.node),
- ]).isRequired,
-};
-
-export default AppWrapper;
diff --git a/src/containers/AppWrapper/index.test.tsx b/src/containers/AppWrapper/index.test.tsx
deleted file mode 100644
index 00e88297..00000000
--- a/src/containers/AppWrapper/index.test.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-import AppWrapper from './index';
-
-describe('AppWrapper', () => {
- it('should render children without modification', () => {
- render(
-
- Test Child
- ,
- );
-
- expect(screen.getByText('Test Child')).toBeInTheDocument();
- });
-});
diff --git a/src/containers/Dashboard/index.jsx b/src/containers/Dashboard/index.jsx
index 7d53e046..0bf53f46 100644
--- a/src/containers/Dashboard/index.jsx
+++ b/src/containers/Dashboard/index.jsx
@@ -6,6 +6,7 @@ import SelectSessionModal from 'containers/SelectSessionModal';
import CoursesPanel from 'containers/CoursesPanel';
import DashboardModalSlot from 'plugin-slots/DashboardModalSlot';
+import MasqueradeBar from 'containers/MasqueradeBar';
import LoadingView from './LoadingView';
import DashboardLayout from './DashboardLayout';
import hooks from './hooks';
@@ -20,24 +21,27 @@ export const Dashboard = () => {
const hasCourses = useMemo(() => data?.courses?.length > 0, [data]);
return (
-
-
{pageTitle}
- {!isPending && (
- <>
-
- {(hasCourses && showSelectSessionModal) &&
}
- >
- )}
-
- {isPending
- ? (
)
- : (
-
-
-
- )}
+ <>
+
+
+
{pageTitle}
+ {!isPending && (
+ <>
+
+ {(hasCourses && showSelectSessionModal) &&
}
+ >
+ )}
+
+ {isPending
+ ? ()
+ : (
+
+
+
+ )}
+
-
+ >
);
};
diff --git a/src/containers/Dashboard/index.test.jsx b/src/containers/Dashboard/index.test.jsx
index 7e2dbc80..2d754adf 100644
--- a/src/containers/Dashboard/index.test.jsx
+++ b/src/containers/Dashboard/index.test.jsx
@@ -24,8 +24,7 @@ jest.mock('containers/CoursesPanel', () => jest.fn(() =>
CoursesPanel
jest.mock('./LoadingView', () => jest.fn(() =>
LoadingView
));
jest.mock('containers/SelectSessionModal', () => jest.fn(() =>
SelectSessionModal
));
jest.mock('./DashboardLayout', () => jest.fn(() =>
DashboardLayout
));
-
-const pageTitle = 'test-page-title';
+jest.mock('containers/MasqueradeBar', () => jest.fn(() =>
MasqueradeBar
)); // Mock the MasqueradeBar
describe('Dashboard', () => {
const createWrapper = (props = {}) => {
@@ -34,7 +33,7 @@ describe('Dashboard', () => {
initIsPending = true,
showSelectSessionModal = true,
} = props;
- hooks.useDashboardMessages.mockReturnValue({ pageTitle });
+ hooks.useDashboardMessages.mockReturnValue({ pageTitle: 'Dashboard' });
const dataMocked = { data: hasCourses ? { courses: [1, 2] } : { courses: [] }, isPending: initIsPending };
useInitializeLearnerHome.mockReturnValue(dataMocked);
useSelectSessionModal.mockReturnValue({ selectSessionModal: showSelectSessionModal ? { cardId: 1 } : null });
@@ -42,11 +41,6 @@ describe('Dashboard', () => {
};
describe('render', () => {
- it('page title is displayed in sr-only h1 tag', () => {
- createWrapper();
- const heading = screen.getByText(pageTitle);
- expect(heading).toHaveClass('sr-only');
- });
describe('initIsPending false', () => {
it('should render DashboardModalSlot', () => {
createWrapper({ initIsPending: false });
@@ -58,6 +52,11 @@ describe('Dashboard', () => {
const selectSessionModal = screen.getByText('SelectSessionModal');
expect(selectSessionModal).toBeInTheDocument();
});
+ it('should render MasqueradeBar', () => {
+ createWrapper({ initIsPending: false });
+ const masqueradeBar = screen.getByText('MasqueradeBar');
+ expect(masqueradeBar).toBeInTheDocument();
+ });
});
describe('courses still loading', () => {
it('should render LoadingView', () => {
@@ -72,6 +71,11 @@ describe('Dashboard', () => {
const dashboardLayout = screen.getByText('DashboardLayout');
expect(dashboardLayout).toBeInTheDocument();
});
+ it('should render MasqueradeBar', () => {
+ createWrapper({ initIsPending: false });
+ const masqueradeBar = screen.getByText('MasqueradeBar');
+ expect(masqueradeBar).toBeInTheDocument();
+ });
});
});
});
diff --git a/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx b/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx
index f13177ff..1af99189 100644
--- a/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx
+++ b/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx
@@ -9,18 +9,20 @@ const getLearnerHeaderMenu = (
courseSearchUrl,
authenticatedUser,
exploreCoursesClick,
+ pathname,
) => ({
mainMenu: [
{
type: 'item',
href: '/',
content: formatMessage(messages.course),
- isActive: true,
+ isActive: pathname === '/',
},
...(getConfig().ENABLE_PROGRAMS ? [{
type: 'item',
- href: `${urls.programsUrl()}`,
+ href: getConfig().ENABLE_PROGRAM_DASHBOARD ? '/programs' : `${urls.programsUrl()}`,
content: formatMessage(messages.program),
+ isActive: pathname === '/programs',
}] : []),
...(!getConfig().NON_BROWSABLE_COURSES ? [{
type: 'item',
diff --git a/src/containers/LearnerDashboardHeader/hooks.js b/src/containers/LearnerDashboardHeader/hooks.js
index 5367ab3b..d585b2d7 100644
--- a/src/containers/LearnerDashboardHeader/hooks.js
+++ b/src/containers/LearnerDashboardHeader/hooks.js
@@ -15,10 +15,10 @@ export const findCoursesNavClicked = (href) => track.findCourses.findCoursesClic
});
export const useLearnerDashboardHeaderMenu = ({
- courseSearchUrl, authenticatedUser, exploreCoursesClick,
+ courseSearchUrl, authenticatedUser, exploreCoursesClick, pathname,
}) => {
const { formatMessage } = useIntl();
- return getLearnerHeaderMenu(formatMessage, courseSearchUrl, authenticatedUser, exploreCoursesClick);
+ return getLearnerHeaderMenu(formatMessage, courseSearchUrl, authenticatedUser, exploreCoursesClick, pathname);
};
export default {
diff --git a/src/containers/LearnerDashboardHeader/index.jsx b/src/containers/LearnerDashboardHeader/index.jsx
index 6f3b31d9..ec071b96 100644
--- a/src/containers/LearnerDashboardHeader/index.jsx
+++ b/src/containers/LearnerDashboardHeader/index.jsx
@@ -1,19 +1,26 @@
import React from 'react';
+import { Helmet } from 'react-helmet';
-import MasqueradeBar from 'containers/MasqueradeBar';
+import { getConfig } from '@edx/frontend-platform';
+import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import Header from '@edx/frontend-component-header';
import { useInitializeLearnerHome } from 'data/hooks';
import urls from 'data/services/lms/urls';
+import { useLocation } from 'react-router-dom';
+import { useDashboardMessages } from 'containers/Dashboard/hooks';
import ConfirmEmailBanner from './ConfirmEmailBanner';
-
+import appMessages from '../../messages';
import { useLearnerDashboardHeaderMenu, findCoursesNavClicked } from './hooks';
-
import './index.scss';
export const LearnerDashboardHeader = () => {
const { authenticatedUser } = React.useContext(AppContext);
+ const { formatMessage } = useIntl();
+ const { pageTitle } = useDashboardMessages();
+ const location = useLocation();
+ const { pathname } = location;
const { data: learnerData } = useInitializeLearnerHome();
const courseSearchUrl = learnerData?.platformSettings?.courseSearchUrl || '';
@@ -25,17 +32,22 @@ export const LearnerDashboardHeader = () => {
courseSearchUrl,
authenticatedUser,
exploreCoursesClick,
+ pathname,
});
return (
<>
+
+ {formatMessage(appMessages.pageTitle)}
+
+
-
+
{pageTitle}
>
);
};
diff --git a/src/containers/LearnerDashboardHeader/index.test.jsx b/src/containers/LearnerDashboardHeader/index.test.jsx
index a8cd80be..434917e3 100644
--- a/src/containers/LearnerDashboardHeader/index.test.jsx
+++ b/src/containers/LearnerDashboardHeader/index.test.jsx
@@ -1,8 +1,10 @@
import { mergeConfig } from '@edx/frontend-platform';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { useLocation } from 'react-router-dom';
import urls from 'data/services/lms/urls';
+import { useDashboardMessages } from 'containers/Dashboard/hooks';
import LearnerDashboardHeader from '.';
import { findCoursesNavClicked } from './hooks';
@@ -22,21 +24,37 @@ jest.mock('./hooks', () => ({
findCoursesNavClicked: jest.fn(),
}));
+jest.mock('react-router-dom', () => ({
+ useLocation: jest.fn(() => ({
+ pathname: '/',
+ })),
+}));
+
const mockedHeaderProps = jest.fn();
-jest.mock('containers/MasqueradeBar', () => jest.fn(() =>
MasqueradeBar
));
jest.mock('./ConfirmEmailBanner', () => jest.fn(() =>
ConfirmEmailBanner
));
jest.mock('@edx/frontend-component-header', () => jest.fn((props) => {
mockedHeaderProps(props);
return
Header
;
}));
+jest.mock('containers/Dashboard/hooks', () => ({
+ useDashboardMessages: jest.fn(),
+}));
+
+const pageTitle = 'test-page-title';
describe('LearnerDashboardHeader', () => {
beforeEach(() => jest.clearAllMocks());
+
+ it('page title is displayed in sr-only h1 tag', () => {
+ useDashboardMessages.mockReturnValue({ pageTitle });
+ render(
);
+ const heading = screen.getByText(pageTitle);
+ expect(heading).toHaveClass('sr-only');
+ });
it('renders and discover url is correct', () => {
mergeConfig({ ORDER_HISTORY_URL: 'test-url' });
render(
);
expect(screen.getByText('ConfirmEmailBanner')).toBeInTheDocument();
- expect(screen.getByText('MasqueradeBar')).toBeInTheDocument();
expect(screen.getByText('Header')).toBeInTheDocument();
const props = mockedHeaderProps.mock.calls[0][0];
const { mainMenuItems } = props;
@@ -60,6 +78,26 @@ describe('LearnerDashboardHeader', () => {
const { mainMenuItems } = props;
expect(mainMenuItems.length).toBe(3);
});
+
+ it('should highlight the active tab depending on the pathname', () => {
+ render(
);
+ const props = mockedHeaderProps.mock.calls[0][0];
+ const { mainMenuItems } = props;
+ expect(mainMenuItems[0].isActive).toBe(true);
+ });
+
+ it('should highlight the programs tab if dashboard is enabled and on the programs page', () => {
+ mergeConfig({ ENABLE_PROGRAMS: true, ENABLE_PROGRAM_DASHBOARD: true });
+ useLocation.mockReturnValueOnce({
+ pathname: '/programs',
+ });
+ render(
);
+ const props = mockedHeaderProps.mock.calls[0][0];
+ const { mainMenuItems } = props;
+ expect(mainMenuItems[0].isActive).toBe(false);
+ expect(mainMenuItems[1].isActive).toBe(true);
+ });
+
it('should not display Discover New tab if it is disabled by configuration', () => {
mergeConfig({ NON_BROWSABLE_COURSES: true });
render(
);
diff --git a/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.test.tsx b/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.test.tsx
new file mode 100644
index 00000000..10156b7e
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.test.tsx
@@ -0,0 +1,64 @@
+import { render, screen } from '@testing-library/react';
+import { getConfig } from '@edx/frontend-platform';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import ExploreProgramsCTA from './ExploreProgramsCTA';
+import messages from './messages';
+
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(() => ({
+ LMS_BASE_URL: 'https://courses.example.com',
+ EXPLORE_PROGRAMS_URL: null,
+ })),
+}));
+
+describe('ExploreProgramsCTA', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderComponent = (props = {}) => render(
+
+
+ ,
+ );
+
+ it('renders the expected CTA text when there are enrollments', () => {
+ renderComponent();
+
+ expect(screen.getByText(messages.exploreProgramsCTAText.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders the expected CTA when there are no enrollments', () => {
+ renderComponent({ hasEnrollments: false });
+
+ expect(screen.getByText(messages.hasNoEnrollmentsText.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders the button with the expected text', () => {
+ renderComponent();
+
+ expect(screen.getByRole('link', { name: messages.exploreProgramsCTAButtonText.defaultMessage })).toBeInTheDocument();
+ });
+
+ it('uses EXPLORE_PROGRAMS_URL when it is defined', () => {
+ const customUrl = 'https://custom.explore.url/programs';
+ (getConfig as jest.Mock).mockReturnValueOnce({
+ LMS_BASE_URL: 'https://courses.example.com',
+ EXPLORE_PROGRAMS_URL: customUrl,
+ });
+
+ renderComponent();
+
+ const button = screen.getByRole('link', { name: messages.exploreProgramsCTAButtonText.defaultMessage });
+ expect(button).toHaveAttribute('href', customUrl);
+ });
+
+ it('falls back to LMS_BASE_URL/courses when EXPLORE_PROGRAMS_URL is not defined', () => {
+ renderComponent();
+
+ const button = screen.getByRole('link', { name: messages.exploreProgramsCTAButtonText.defaultMessage });
+ const expectedFallbackUrl = `${getConfig().LMS_BASE_URL}/courses`;
+ expect(button).toHaveAttribute('href', expectedFallbackUrl);
+ });
+});
diff --git a/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.tsx b/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.tsx
new file mode 100644
index 00000000..073bf7a0
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ExploreProgramsCTA.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { getConfig } from '@edx/frontend-platform';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Card, Button } from '@openedx/paragon';
+import { Search } from '@openedx/paragon/icons';
+import { ExploreProgramsCTAProps } from '../data/types';
+import messages from './messages';
+
+const ExploreProgramsCTA: React.FC
= ({
+ hasEnrollments = true,
+}) => {
+ const { formatMessage } = useIntl();
+
+ const href = getConfig().EXPLORE_PROGRAMS_URL || `${getConfig().LMS_BASE_URL}/courses`;
+ return (
+
+
+ {hasEnrollments ? (
+ formatMessage(messages.exploreProgramsCTAText)
+ ) : (
+
+ {formatMessage(messages.hasNoEnrollmentsText)}
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default ExploreProgramsCTA;
diff --git a/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.test.tsx b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.test.tsx
new file mode 100644
index 00000000..7069dfea
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.test.tsx
@@ -0,0 +1,131 @@
+import { render, RenderResult, screen } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import ProgramListCard from './ProgramListCard';
+import { ProgramData } from '../../../data/types';
+
+jest.mock('react-router-dom', () => ({
+ Link: jest.fn(({ children, ...props }) => {children}),
+}));
+
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(() => ({
+ LMS_BASE_URL: 'test-base-url',
+ })),
+}));
+
+const mockBaseProgram = {
+ uuid: 'test-uuid',
+ title: 'test-title',
+ type: 'test-type',
+ bannerImage: {
+ xSmall: { url: 'banner-xSmall.jpg', width: 348, height: 116 },
+ small: { url: 'banner-small.jpg', width: 435, height: 145 },
+ medium: { url: 'banner-medium.jpg', width: 726, height: 242 },
+ large: { url: 'banner-large.jpg', width: 1440, height: 480 },
+ },
+ authoringOrganizations: [
+ {
+ uuid: 'org-uuid-1',
+ key: 'test-key',
+ name: 'test-org-1',
+ logoImageUrl: 'test-logo.png',
+ certificateLogoImageUrl: 'test-cert-logo.png',
+ },
+ ],
+ progress: {
+ inProgress: 1,
+ notStarted: 2,
+ completed: 3,
+ },
+};
+
+const mockMultipleOrgProgram = {
+ ...mockBaseProgram,
+ authoringOrganizations: [
+ {
+ uuid: 'org-uuid-1',
+ name: 'MIT',
+ key: 'MITx',
+ logoImageUrl: 'mit-logo.png',
+ certificateLogoImageUrl: 'mit-cert-logo-1.png',
+ },
+ {
+ uuid: 'org-uuid-2',
+ name: 'Harvard',
+ key: 'Harvardx',
+ logoImageUrl: 'harvard-logo.png',
+ certificateLogoImageUrl: 'harvard-cert-logo-2.png',
+ },
+ ],
+};
+
+describe('ProgramListCard', () => {
+ const renderComponent = (programData: ProgramData = mockBaseProgram): RenderResult => render(
+
+
+ ,
+ );
+
+ it('renders all data for program', () => {
+ renderComponent();
+ expect(screen.getByText(mockBaseProgram.title)).toBeInTheDocument();
+ expect(screen.getByText(mockBaseProgram.type)).toBeInTheDocument();
+ expect(screen.getByText(mockBaseProgram.authoringOrganizations[0].key)).toBeInTheDocument();
+ const logoImageNode = screen.getByAltText(mockBaseProgram.authoringOrganizations[0].key);
+ expect(logoImageNode).toHaveAttribute('src', mockBaseProgram.authoringOrganizations[0].logoImageUrl);
+ expect(screen.getByText(mockBaseProgram.progress.inProgress)).toBeInTheDocument();
+ expect(screen.getByText('In progress')).toBeInTheDocument();
+ expect(screen.getByText(mockBaseProgram.progress.completed)).toBeInTheDocument();
+ expect(screen.getByText('Completed')).toBeInTheDocument();
+ expect(screen.getByText(mockBaseProgram.progress.notStarted)).toBeInTheDocument();
+ expect(screen.getByText('Remaining')).toBeInTheDocument();
+ });
+
+ it('renders names of all organizations when more than one', () => {
+ renderComponent(mockMultipleOrgProgram);
+ const aggregatedOrganizations = mockMultipleOrgProgram.authoringOrganizations.map(org => org.key).join(', ');
+ expect(screen.getByText(aggregatedOrganizations)).toBeInTheDocument();
+ });
+
+ it('doesnt render logo of organizations when more than one', () => {
+ const { queryByAltText } = renderComponent(mockMultipleOrgProgram);
+ const logoImageNode = queryByAltText(mockMultipleOrgProgram.authoringOrganizations[0].key);
+ expect(logoImageNode).toBeNull();
+ });
+
+ it('each card links to a progress page using the program uuid', async () => {
+ const { getByTestId } = renderComponent();
+ const programCard = getByTestId('program-list-card');
+ expect(programCard).toHaveAttribute('to', 'test-base-url/dashboard/programs/test-uuid');
+ });
+
+ it.each([{
+ width: 1450,
+ size: 'large',
+ },
+ {
+ width: 1300,
+ size: 'large',
+ },
+ {
+ width: 1000,
+ size: 'large',
+ },
+ {
+ width: 800,
+ size: 'medium',
+ },
+ {
+ width: 600,
+ size: 'small',
+ },
+ {
+ width: 500,
+ size: 'xSmall',
+ }])('tests window size', ({ width, size }) => {
+ window.innerWidth = width;
+ const { getByAltText } = renderComponent();
+ const imageCap = getByAltText('program card image for test-title');
+ expect(imageCap).toHaveAttribute('src', `banner-${size}.jpg`);
+ });
+});
diff --git a/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx
new file mode 100644
index 00000000..ee76cfa8
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { getConfig } from '@edx/frontend-platform';
+import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png';
+import {
+ breakpoints,
+ useWindowSize,
+ Card,
+ Row,
+} from '@openedx/paragon';
+import { ProgramCardProps } from '../data/types';
+import ProgressCategoryBubbles from './ProgressCategoryBubbles';
+
+const ProgramListCard: React.FC = ({
+ program,
+}) => {
+ const { width: windowWidth } = useWindowSize();
+
+ const getBannerImageURL = (): string => {
+ let imageURL = '';
+ // We need to check that the breakpoint value exists before using it
+ // Otherwise TypeScript will flag it as it can potentially be undefined in Paragon
+ if (program.bannerImage && Object.keys(program.bannerImage).length > 0) {
+ if (!windowWidth) {
+ return program.bannerImage.medium.url;
+ }
+
+ if (typeof breakpoints.large.minWidth === 'number' && windowWidth >= breakpoints.large.minWidth) {
+ imageURL = program.bannerImage.large.url;
+ } else if (typeof breakpoints.medium.minWidth === 'number' && windowWidth >= breakpoints.medium.minWidth) {
+ imageURL = program.bannerImage.medium.url;
+ } else if (typeof breakpoints.small.minWidth === 'number' && windowWidth >= breakpoints.small.minWidth) {
+ imageURL = program.bannerImage.small.url;
+ } else {
+ imageURL = program.bannerImage.xSmall.url;
+ }
+ }
+ return imageURL;
+ };
+
+ const getOrgImageUrl = (): string => {
+ if (program.authoringOrganizations?.length === 1 && program.authoringOrganizations[0].logoImageUrl) {
+ return program.authoringOrganizations[0].logoImageUrl;
+ }
+ return '';
+ };
+
+ return (
+
+
+
+
+ {program.authoringOrganizations && (
+
+ {program.authoringOrganizations.map(org => org.key).join(', ')}
+
+ )}
+
+ {program.type}
+
+
+
+
+ {program.title}
+
+
+
+
+
+ );
+};
+
+export default ProgramListCard;
diff --git a/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.test.tsx b/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.test.tsx
new file mode 100644
index 00000000..2aaffac4
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import ProgressCategoryBubbles from './ProgressCategoryBubbles';
+
+describe('ProgressCategoryBubbles', () => {
+ it('renders the correct values for each category', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('completed-count')).toHaveTextContent('0');
+ expect(screen.getByTestId('in-progress-count')).toHaveTextContent('1');
+ expect(screen.getByTestId('remaining-count')).toHaveTextContent('2');
+ });
+});
diff --git a/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.tsx b/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.tsx
new file mode 100644
index 00000000..54d0cec4
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgressCategoryBubbles.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { Bubble, Stack } from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+
+import { Progress } from '../../../data/types';
+
+const ProgressCategoryBubbles: React.FC