diff --git a/.env.development b/.env.development
index 73183ccc2..56eb9a82c 100644
--- a/.env.development
+++ b/.env.development
@@ -36,7 +36,7 @@ ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
-LEARNING_BASE_URL='http://localhost:2000'
+LEARNING_BASE_URL='http://localhost:2010'
SESSION_COOKIE_DOMAIN='localhost'
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
diff --git a/package-lock.json b/package-lock.json
index 4cd0b6db8..34a14005f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.0.1",
"license": "AGPL-3.0",
"dependencies": {
- "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
+ "@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
@@ -23,6 +23,7 @@
"@openedx/paragon": "^23.4.5",
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "^2.0.0",
+ "@tanstack/react-query": "^5.95.2",
"classnames": "^2.3.1",
"core-js": "3.48.0",
"font-awesome": "4.7.0",
@@ -144,6 +145,7 @@
"integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
@@ -2309,6 +2311,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
},
@@ -2331,6 +2334,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
}
@@ -2554,6 +2558,7 @@
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.5.tgz",
"integrity": "sha512-imExY37cxE7qzKYg3gaqcdfhc0rzpV1DEFmy6PPCJg4m+cycQNiXtAKl3nITkcQkzhV0JYh3qttEgq6d4a1QXw==",
"license": "AGPL-3.0",
+ "peer": true,
"dependencies": {
"@cospired/i18n-iso-languages": "4.2.0",
"@formatjs/intl-pluralrules": "4.3.3",
@@ -2643,6 +2648,7 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2654,6 +2660,7 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2664,6 +2671,7 @@
"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==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -3143,6 +3151,7 @@
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
@@ -3269,6 +3278,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3291,6 +3301,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3313,6 +3324,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3329,6 +3341,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3345,6 +3358,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3361,6 +3375,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3377,6 +3392,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3393,6 +3409,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3409,6 +3426,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3425,6 +3443,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3441,6 +3460,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3457,6 +3477,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3479,6 +3500,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3501,6 +3523,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3523,6 +3546,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3545,6 +3569,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3567,6 +3592,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3589,6 +3615,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3611,6 +3638,7 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -3630,6 +3658,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3649,6 +3678,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3668,6 +3698,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -4208,6 +4239,7 @@
"integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/core": "^7.11.6",
"@jest/types": "^29.6.3",
@@ -4245,6 +4277,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",
@@ -4738,6 +4771,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": {
@@ -4766,6 +4800,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
},
@@ -4836,6 +4871,7 @@
"integrity": "sha512-Iu4/GPq90Xr/MSWnonn2qX8VDhI89HN7KOYBZ0/sxmAQgvXXNc7OYNC7kumvzbYzKueJQTyZoUYS7UjKB/n1WA==",
"devOptional": true,
"license": "AGPL-3.0",
+ "peer": true,
"dependencies": {
"@babel/cli": "7.24.8",
"@babel/core": "7.24.9",
@@ -4997,6 +5033,7 @@
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.19.1.tgz",
"integrity": "sha512-c/cWnvZsGS7xyq0tJpssmv2oyfYG6Fuawy6EzWy8CYiQ4oD67EVuSwBInCfSJoNZhvvkUE+4B/YhDIRGUVDz5w==",
"license": "Apache-2.0",
+ "peer": true,
"workspaces": [
"example",
"component-generator",
@@ -5571,6 +5608,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"
@@ -5619,7 +5657,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@reduxjs/toolkit/node_modules/redux-thunk": {
"version": "3.1.0",
@@ -5881,6 +5920,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",
@@ -5983,6 +6023,32 @@
"url": "https://github.com/sponsors/gregberge"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
+ "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
+ "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.95.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -6123,6 +6189,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": {
@@ -6134,8 +6201,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",
@@ -6497,6 +6563,7 @@
"integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -6550,6 +6617,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"
@@ -6702,6 +6770,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",
@@ -6750,6 +6819,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",
@@ -6951,6 +7021,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6964,6 +7035,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6977,6 +7049,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6990,6 +7063,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7003,6 +7077,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7016,6 +7091,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7029,6 +7105,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7042,6 +7119,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7055,6 +7133,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7068,6 +7147,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7081,6 +7161,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7094,6 +7175,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7107,6 +7189,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7120,6 +7203,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7133,6 +7217,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7146,6 +7231,7 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -7162,6 +7248,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7175,6 +7262,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7188,6 +7276,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7461,6 +7550,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7558,6 +7648,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",
@@ -8068,6 +8159,7 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
@@ -8112,6 +8204,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",
@@ -8592,6 +8685,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -10244,8 +10338,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",
@@ -10801,6 +10894,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": {
@@ -10814,6 +10908,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.4.0",
@@ -10871,6 +10966,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",
@@ -10913,6 +11009,7 @@
"integrity": "sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"eslint-config-airbnb-base": "^15.0.0"
},
@@ -11315,6 +11412,7 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@@ -11372,6 +11470,7 @@
"integrity": "sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.20.7",
"aria-query": "^5.1.3",
@@ -11410,6 +11509,7 @@
"integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"array-includes": "^3.1.6",
"array.prototype.flatmap": "^1.3.1",
@@ -11441,6 +11541,7 @@
"integrity": "sha512-Ck77j8hF7l9N4S/rzSLOWEKpn994YH6iwUK8fr9mXIaQvGpQYmOnQLbiue1u5kI5T1y+gdgqosnEAO9NCz0DBg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -12524,6 +12625,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,
@@ -14618,6 +14720,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -17639,6 +17742,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
@@ -18424,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",
@@ -18440,7 +18543,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -18483,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",
@@ -18752,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"
},
@@ -18921,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"
@@ -19069,6 +19174,7 @@
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.8.9.tgz",
"integrity": "sha512-TUfj5E7lyUDvz/GtovC9OMh441kBr08rtIbgh3p0R8iF3hVY+V2W9Am7rb8BpJ/29BH1utJOqOOhmvEVh3GfZg==",
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.2.4",
"@formatjs/icu-messageformat-parser": "2.9.4",
@@ -19199,6 +19305,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
"integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/react-redux": "^7.1.20",
@@ -19225,6 +19332,7 @@
"integrity": "sha512-FPvF2XxTSikpJxcr+bHut2H4gJ17+18Uy20D5/F+SKzFap62R3cM5wH6b8WN3LyGSYeQilLEcJcR1fjBSI2S1A==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -19314,6 +19422,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"
@@ -19534,6 +19643,7 @@
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.9.2"
}
@@ -20173,6 +20283,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",
@@ -21158,6 +21269,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",
@@ -21659,6 +21771,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -21797,6 +21910,7 @@
"integrity": "sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"bs-logger": "0.x",
"fast-json-stable-stringify": "2.x",
@@ -21933,7 +22047,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",
@@ -21987,6 +22102,7 @@
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"devOptional": true,
"license": "(MIT OR CC0-1.0)",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -22092,6 +22208,7 @@
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -22240,6 +22357,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -22599,6 +22717,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",
@@ -22707,6 +22826,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",
@@ -22787,6 +22907,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/package.json b/package.json
index 27d3a2a1f..ed44709e6 100755
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"access": "public"
},
"dependencies": {
- "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
+ "@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
@@ -43,6 +43,7 @@
"@openedx/paragon": "^23.4.5",
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "^2.0.0",
+ "@tanstack/react-query": "^5.95.2",
"classnames": "^2.3.1",
"core-js": "3.48.0",
"font-awesome": "4.7.0",
diff --git a/src/App.jsx b/src/App.jsx
index 2c148f98e..6158260dc 100755
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,12 +1,10 @@
import React from 'react';
-import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { ErrorPage, AppContext } from '@edx/frontend-platform/react';
-import { FooterSlot } from '@edx/frontend-component-footer';
import { Alert } from '@openedx/paragon';
import { RequestKeys } from 'data/constants/requests';
@@ -22,9 +20,6 @@ import track from 'tracking';
import fakeData from 'data/services/lms/fakeData/courses';
-import AppWrapper from 'containers/AppWrapper';
-import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
-
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
import './App.scss';
@@ -72,28 +67,16 @@ export const App = () => {
}
}, [authenticatedUser, loadData]);
return (
- <>
-
- {formatMessage(messages.pageTitle)}
-
-
-
-
-
-
- {hasNetworkFailure
- ? (
-
-
-
- ) : (
-
- )}
-
-
-
-
- >
+
+ {hasNetworkFailure
+ ? (
+
+
+
+ ) : (
+
+ )}
+
);
};
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 102d3792d..61ea55a23 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -8,12 +8,7 @@ import { reduxHooks } from 'hooks';
import { App } from './App';
import messages from './messages';
-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('data/redux', () => ({
selectors: 'redux.selectors',
actions: 'redux.actions',
@@ -45,24 +40,6 @@ reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail });
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();
@@ -70,7 +47,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({});
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -83,7 +59,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' });
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -96,7 +71,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' });
render();
});
- runBasicTests();
it('loads dashboard', () => {
const dashboard = screen.getByText('Dashboard');
expect(dashboard).toBeInTheDocument();
@@ -109,7 +83,6 @@ describe('App router component', () => {
getConfig.mockReturnValue({});
render();
});
- runBasicTests();
it('loads error page', () => {
const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
@@ -123,7 +96,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/containers/AppWrapper/index.jsx b/src/containers/AppWrapper/index.jsx
deleted file mode 100644
index 72f4a9260..000000000
--- 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/Dashboard/index.jsx b/src/containers/Dashboard/index.jsx
index ebdb1ef78..fc0ca08e0 100644
--- a/src/containers/Dashboard/index.jsx
+++ b/src/containers/Dashboard/index.jsx
@@ -13,14 +13,12 @@ import './index.scss';
export const Dashboard = () => {
hooks.useInitializeDashboard();
- const { pageTitle } = hooks.useDashboardMessages();
const hasCourses = reduxHooks.useHasCourses();
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
return (
-
{pageTitle}
{!initIsPending && (
<>
diff --git a/src/containers/Dashboard/index.test.jsx b/src/containers/Dashboard/index.test.jsx
index 48f03cfbe..323ecd380 100644
--- a/src/containers/Dashboard/index.test.jsx
+++ b/src/containers/Dashboard/index.test.jsx
@@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
-import hooks from './hooks';
import Dashboard from '.';
jest.mock('hooks', () => ({
@@ -24,8 +23,6 @@ jest.mock('./LoadingView', () => jest.fn(() =>
LoadingView
));
jest.mock('containers/SelectSessionModal', () => jest.fn(() =>
SelectSessionModal
));
jest.mock('./DashboardLayout', () => jest.fn(() =>
DashboardLayout
));
-const pageTitle = 'test-page-title';
-
describe('Dashboard', () => {
const createWrapper = (props = {}) => {
const {
@@ -33,7 +30,6 @@ describe('Dashboard', () => {
initIsPending = true,
showSelectSessionModal = true,
} = props;
- hooks.useDashboardMessages.mockReturnValue({ pageTitle });
reduxHooks.useHasCourses.mockReturnValue(hasCourses);
reduxHooks.useRequestIsPending.mockReturnValue(initIsPending);
reduxHooks.useShowSelectSessionModal.mockReturnValue(showSelectSessionModal);
@@ -41,11 +37,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 });
diff --git a/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx b/src/containers/LearnerDashboardHeader/LearnerDashboardMenu.jsx
index f13177ffa..1af991898 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 5367ab3b5..d585b2d75 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 2cd167658..43a4c8780 100644
--- a/src/containers/LearnerDashboardHeader/index.jsx
+++ b/src/containers/LearnerDashboardHeader/index.jsx
@@ -1,20 +1,28 @@
import React from 'react';
+import { Helmet } from 'react-helmet';
+import { getConfig } from '@edx/frontend-platform';
+import { useIntl } from '@edx/frontend-platform/i18n';
import MasqueradeBar from 'containers/MasqueradeBar';
import { AppContext } from '@edx/frontend-platform/react';
import Header from '@edx/frontend-component-header';
import { reduxHooks } from '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 { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
+ const { pageTitle } = useDashboardMessages();
+ const location = useLocation();
+ const { pathname } = location;
const exploreCoursesClick = () => {
findCoursesNavClicked(urls.baseAppUrl(courseSearchUrl));
@@ -24,16 +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 6179b4526..71f71d9e2 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';
@@ -20,6 +22,12 @@ 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
));
@@ -27,9 +35,21 @@ 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(
);
@@ -58,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 000000000..97ed8a2e6
--- /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.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 000000000..bca5446c3
--- /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 000000000..9493abb47
--- /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 }) => {
+ global.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 000000000..4d0c20e2b
--- /dev/null
+++ b/src/containers/ProgramDashboard/ProgramsList/ProgramListCard.tsx
@@ -0,0 +1,89 @@
+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 (!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 => {
+ // Otherwise use the logoImageUrl and key for the organization
+ 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 000000000..1bc5c5bd9
--- /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 000000000..3a9c0b17a
--- /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