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 = ({ notStarted, inProgress, completed }) => { + const { formatMessage } = useIntl(); + return ( + + + + {completed} + +
+ {formatMessage(messages.progressCategoryBubblesSuccess)} +
+
+ + + + {inProgress} + +
+ {formatMessage(messages.progressCategoryBubblesInProgress)} +
+
+ + + + {notStarted} + +
+ {formatMessage(messages.progressCategoryBubblesRemaining)} +
+
+
+ ); +}; + +export default ProgressCategoryBubbles; diff --git a/src/containers/ProgramDashboard/ProgramsList/index.scss b/src/containers/ProgramDashboard/ProgramsList/index.scss new file mode 100644 index 000000000..c5ea45b70 --- /dev/null +++ b/src/containers/ProgramDashboard/ProgramsList/index.scss @@ -0,0 +1,16 @@ +// The current Truncate component in Paragon is deprecated and soon to be removed +// See https://github.com/openedx/paragon/issues/3311 for developments on this issue + +.truncate-text-1 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.truncate-text-2 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} diff --git a/src/containers/ProgramDashboard/ProgramsList/index.test.tsx b/src/containers/ProgramDashboard/ProgramsList/index.test.tsx new file mode 100644 index 000000000..a015d54ba --- /dev/null +++ b/src/containers/ProgramDashboard/ProgramsList/index.test.tsx @@ -0,0 +1,129 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { getConfig } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { logError } from '@edx/frontend-platform/logging'; + +import ProgramsList from '.'; +import { getProgramsListData } from '../data/api'; +import ProgramListCard from './ProgramListCard'; +import ExploreProgramsCTA from './ExploreProgramsCTA'; +import messages from './messages'; + +// Mock API and external utilities +jest.mock('../data/api', () => ({ + getProgramsListData: jest.fn(), +})); +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + CONTACT_URL: 'test-contact-url', + })), +})); + +// Mock Child Components +jest.mock('./ProgramListCard', () => jest.fn(({ program }) => ( +
{program.title}
+))); +jest.mock('./ExploreProgramsCTA', () => jest.fn(() => ( +
+))); + +// Mock Data +const mockApiData = [ + { uuid: '111-aaa', title: 'Data Science Program' }, + { uuid: '222-bbb', title: 'UX Design Program' }, +]; + +describe('ProgramsList', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Set up a successful mock API response by default + (getProgramsListData as jest.Mock).mockResolvedValue(mockApiData); + }); + + const renderComponent = () => render( + + + , + ); + + it('renders header text and ExploreProgramsCTA', async () => { + renderComponent(); + await waitFor(() => { + expect(screen.getByText(messages.programsListHeaderText.defaultMessage)).toBeInTheDocument(); + expect(screen.getByTestId('explore-programs-cta')).toBeInTheDocument(); + }); + }); + + it('fetches program data on mount', async () => { + renderComponent(); + + expect(getProgramsListData).toHaveBeenCalledTimes(1); + }); + + it('renders ProgramListCard components upon successful API response', async () => { + renderComponent(); + + // Wait for the data to load and cards to render + await waitFor(() => { + // Expect both cards to be rendered + expect(screen.getAllByTestId('program-list-card')).toHaveLength(2); + + // Check if ProgramListCard was called with the processed data + // Check for the first card + expect(ProgramListCard).toHaveBeenCalledWith( + expect.objectContaining({ + program: mockApiData[0], + }), + {}, + ); + // Check for the second card + expect(ProgramListCard).toHaveBeenCalledWith( + expect.objectContaining({ + program: mockApiData[1], + }), + {}, + ); + }); + }); + + it('renders the ExploreProgramsCTA with "hasEnrollments" set to false if there are no program enrollments', async () => { + (getProgramsListData as jest.Mock).mockResolvedValueOnce({ data: [] }); + renderComponent(); + + await waitFor(() => { + expect(ExploreProgramsCTA).toHaveBeenCalledWith( + expect.objectContaining( + { + hasEnrollments: false, + }, + ), + {}, + ); + }); + }); + + it('calls logError if the API request fails', async () => { + const mockError = new Error('Network failed'); + (getProgramsListData as jest.Mock).mockRejectedValue(mockError); + + const mockContactUrl = 'mock-contact-url'; + + getConfig.mockReturnValueOnce({ + CONTACT_URL: mockContactUrl, + }); + + renderComponent(); + + // Wait for the asynchronous error handling path to execute + await waitFor(() => { + expect(logError).toHaveBeenCalledWith(mockError); + }); + + // Ensure no cards are rendered on failure + expect(screen.queryAllByTestId('program-list-card')).toHaveLength(0); + expect(screen.getByRole('link', { name: mockContactUrl })).toBeInTheDocument(); + }); +}); diff --git a/src/containers/ProgramDashboard/ProgramsList/index.tsx b/src/containers/ProgramDashboard/ProgramsList/index.tsx new file mode 100644 index 000000000..03e3a203c --- /dev/null +++ b/src/containers/ProgramDashboard/ProgramsList/index.tsx @@ -0,0 +1,100 @@ +import React, { useState, useEffect } from 'react'; +import { + Alert, CardGrid, Col, Container, Row, Spinner, +} from '@openedx/paragon'; +import { getConfig } from '@edx/frontend-platform'; +import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import appMessages from 'messages'; +import { getProgramsListData } from '../data/api'; +import { ProgramData } from '../data/types'; +import ProgramListCard from './ProgramListCard'; +import ExploreProgramsCTA from './ExploreProgramsCTA'; +import messages from './messages'; + +import './index.scss'; + +const ProgramsList: React.FC = () => { + const { formatMessage } = useIntl(); + const [programsData, setProgramsData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorState, setErrorState] = useState(false); + + useEffect(() => { + getProgramsListData() + .then(responseData => { + setProgramsData(camelCaseObject(responseData)); + setIsLoading(false); + }) + .catch(err => { + logError(err); + setIsLoading(false); + setErrorState(true); + }); + }, []); + + const renderPrograms = () => { + if (isLoading) { + return ( + + + + ); + } + if (programsData.length > 0) { + return ( + <> + + + {programsData.map(program => ( + + ))} + + + + + + + ); + } + return ( + + + + ); + }; + + const renderFailureAlert = () => { + const contactUrl = getConfig().CONTACT_URL; + return ( + + {formatMessage(messages.errorLoadingProgramEnrollments, { + contactSupportUrl: ( + + {contactUrl} + + ), + })} + + ); + }; + + return ( + +

+ {formatMessage(messages.programsListHeaderText)} +

+ + {errorState ? ( + renderFailureAlert() + ) : ( + renderPrograms() + )} + +
+ ); +}; + +export default ProgramsList; diff --git a/src/containers/ProgramDashboard/ProgramsList/messages.ts b/src/containers/ProgramDashboard/ProgramsList/messages.ts new file mode 100644 index 000000000..a6e9b5eff --- /dev/null +++ b/src/containers/ProgramDashboard/ProgramsList/messages.ts @@ -0,0 +1,51 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + programDashboardPageTitle: { + defaultMessage: 'Program Dashboard', + id: 'program.dashboard.page.title', + description: 'Page title for Program Dashboard', + }, + programsListHeaderText: { + defaultMessage: 'My Programs', + id: 'programs.list.header.text', + description: 'Header text for the programs list', + }, + exploreProgramsCTAText: { + defaultMessage: 'Browse recently launched courses and see what\'s new in your favorite subjects', + id: 'explore.courses.cta.text', + description: 'Call-to-action text for the explore courses component', + }, + exploreProgramsCTAButtonText: { + defaultMessage: 'Explore new programs', + id: 'explore.courses.cta.button.text', + description: 'Button text for that links to course search page', + }, + hasNoEnrollmentsText: { + defaultMessage: 'You are not enrolled in any programs yet.', + id: 'has.no.enrollments.text', + description: 'Text to display when a learner has not enrolled in any programs.', + }, + progressCategoryBubblesRemaining: { + id: 'dashboard.programs.program.listing.card.remaining.courses.count', + defaultMessage: 'Remaining', + description: 'Label for remaining courses count on program card', + }, + progressCategoryBubblesInProgress: { + id: 'dashboard.programs.program.listing.card.inProgress.courses.count', + defaultMessage: 'In progress', + description: 'Label for in progress courses count on program card', + }, + progressCategoryBubblesSuccess: { + id: 'dashboard.programs.program.listing.card.completed.courses.count', + defaultMessage: 'Completed', + description: 'Label for completed courses count on program card', + }, + errorLoadingProgramEnrollments: { + id: 'alert.error.loading.program.enrollments', + defaultMessage: 'An error occurred while attempting to retrieve program enrollments. Try refreshing page. If that doesn\'t solve the issue, contact support at {contactSupportUrl}.', + description: 'Alert message for failure to load program enrollments', + }, +}); + +export default messages; diff --git a/src/containers/ProgramDashboard/api.ts b/src/containers/ProgramDashboard/api.ts new file mode 100644 index 000000000..22f8c0b32 --- /dev/null +++ b/src/containers/ProgramDashboard/api.ts @@ -0,0 +1,8 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +export async function getProgramsListData() { + const url = `${getConfig().LMS_BASE_URL}/api/dashboard/v0/programs/`; + const response = await getAuthenticatedHttpClient().get(url); + return response; +} diff --git a/src/containers/ProgramDashboard/data/api.test.ts b/src/containers/ProgramDashboard/data/api.test.ts new file mode 100644 index 000000000..a9a1e8c88 --- /dev/null +++ b/src/containers/ProgramDashboard/data/api.test.ts @@ -0,0 +1,27 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getProgramsListData } from './api'; + +const mockGet = jest.fn(() => ({ + data: {}, +})); +const mockLMSBaseUrl = 'http://test-lms-base-url'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(() => ({ + get: mockGet, + })), +})); +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + LMS_BASE_URL: mockLMSBaseUrl, + })), +})); + +describe('API', () => { + it('uses the expected URL to call the endpoint', async () => { + await getProgramsListData(); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(mockGet).toHaveBeenCalledWith(`${mockLMSBaseUrl}/api/dashboard/v0/programs/`); + }); +}); diff --git a/src/containers/ProgramDashboard/data/api.ts b/src/containers/ProgramDashboard/data/api.ts new file mode 100644 index 000000000..7196b392c --- /dev/null +++ b/src/containers/ProgramDashboard/data/api.ts @@ -0,0 +1,8 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +export async function getProgramsListData() { + const url = `${getConfig().LMS_BASE_URL}/api/dashboard/v0/programs/`; + const { data } = await getAuthenticatedHttpClient().get(url); + return data; +} diff --git a/src/containers/ProgramDashboard/data/types.d.ts b/src/containers/ProgramDashboard/data/types.d.ts new file mode 100644 index 000000000..c93a7992a --- /dev/null +++ b/src/containers/ProgramDashboard/data/types.d.ts @@ -0,0 +1,41 @@ +export interface ProgramData { + uuid: string, + title: string, + type: string, + bannerImage: { + small: ImageData, + medium: ImageData, + large: ImageData, + xSmall: ImageData, + }, + authoringOrganizations?: AuthoringOrganization[], + progress: Progress, +} + +export interface ImageData { + height: number, + width: number, + url: string, +} + +export interface AuthoringOrganization { + uuid: string, + key: string, + name: string, + logoImageUrl: string, + certificateLogoImageUrl: string | null, +} + +export interface Progress { + inProgress: number, + notStarted: number, + completed: number, +} + +export interface ProgramCardProps { + program: ProgramData, +} + +export interface ExploreProgramsCTAProps { + hasEnrollments?: boolean, +} diff --git a/src/containers/ProgramDashboard/index.tsx b/src/containers/ProgramDashboard/index.tsx new file mode 100644 index 000000000..e7cf91d18 --- /dev/null +++ b/src/containers/ProgramDashboard/index.tsx @@ -0,0 +1,5 @@ +import ProgramsList from './ProgramsList'; + +export { + ProgramsList, +}; diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 000000000..1c5923252 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: string; + export default value; +} diff --git a/src/hooks/api.js b/src/hooks/api.js index d64a11850..b207e710a 100644 --- a/src/hooks/api.js +++ b/src/hooks/api.js @@ -8,6 +8,7 @@ import api from 'data/services/lms/api'; import * as reduxHooks from 'data/redux/hooks'; import * as module from './api'; +import { useQuery, useMutation } from '@tanstack/react-query'; const { useMakeNetworkRequest } = reduxHooks; @@ -24,39 +25,52 @@ export const useNetworkRequest = (action, args) => { * submission list data. */ export const useInitializeApp = () => { - const loadData = reduxHooks.useLoadData(); - return module.useNetworkRequest(api.initializeList, { - requestKey: RequestKeys.initialize, - onSuccess: ({ data }) => loadData(data), - }); + const { data, error, isLoading } = useQuery('initializeApp', () => api.initializeList()); + + return { + data, + error, + isLoading, + }; }; export const useNewEntitlementEnrollment = (cardId) => { const { uuid } = reduxHooks.useCardEntitlementData(cardId); - const onSuccess = module.useInitializeApp(); - return module.useNetworkRequest( + const { mutate, isLoading, error } = useMutation( (selection) => api.updateEntitlementEnrollment({ uuid, courseId: selection }), - { onSuccess, requestKey: RequestKeys.newEntitlementEnrollment }, ); + + return { + enroll: mutate, + isLoading, + error, + }; }; export const useSwitchEntitlementEnrollment = (cardId) => { const { uuid } = reduxHooks.useCardEntitlementData(cardId); - const onSuccess = module.useInitializeApp(); - const action = (selection) => api.updateEntitlementEnrollment({ uuid, courseId: selection }); - return module.useNetworkRequest( - action, - { onSuccess, requestKey: RequestKeys.switchEntitlementSession }, + const { mutate, isLoading, error } = useMutation( + (selection) => api.updateEntitlementEnrollment({ uuid, courseId: selection }), ); + + return { + switchEnrollment: mutate, + isLoading, + error, + }; }; export const useLeaveEntitlementSession = (cardId) => { const { uuid, isRefundable } = reduxHooks.useCardEntitlementData(cardId); - const onSuccess = module.useInitializeApp(); - return module.useNetworkRequest( + const { mutate, isLoading, error } = useMutation( () => api.deleteEntitlementEnrollment({ uuid, isRefundable }), - { onSuccess, requestKey: RequestKeys.leaveEntitlementSession }, ); + + return { + leaveSession: mutate, + isLoading, + error, + }; }; export const useUnenrollFromCourse = (cardId) => { diff --git a/src/index.jsx b/src/index.jsx index 49e5858ac..16c2d832f 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -5,7 +5,7 @@ import 'regenerator-runtime/runtime'; import React, { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { - Route, Navigate, Routes, + Navigate, Route, Routes, } from 'react-router-dom'; import { @@ -19,8 +19,13 @@ import { APP_INIT_ERROR, initialize, subscribe, + getConfig, mergeConfig, } from '@edx/frontend-platform'; +import { FooterSlot } from '@edx/frontend-component-footer'; + +import LearnerDashboardHeader from 'containers/LearnerDashboardHeader'; +import ProgramsList from './containers/ProgramDashboard'; import { configuration } from './config'; @@ -34,10 +39,15 @@ subscribe(APP_READY, () => { root.render( + } /> + {getConfig().ENABLE_PROGRAM_DASHBOARD && ( + } /> + )} } /> + , ); diff --git a/src/index.test.jsx b/src/index.test.jsx index ba53223f8..26d6856cb 100644 --- a/src/index.test.jsx +++ b/src/index.test.jsx @@ -25,6 +25,9 @@ jest.mock('react-dom/client', () => { }); jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + ENABLE_PROGRAM_DASHBOARD: true, + })), mergeConfig: jest.fn(), ensureConfig: jest.fn(), APP_READY: 'app-is-ready-key', @@ -35,6 +38,12 @@ jest.mock('@edx/frontend-platform', () => ({ jest.mock('data/store', () => ({ redux: 'store' })); jest.mock('./App', () => 'App'); +jest.mock('@edx/frontend-component-footer', () => ({ + FooterSlot: jest.fn(() =>
FooterSlot
), +})); + +jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader'); +jest.mock('containers/ProgramDashboard', () => 'ProgramDashboard'); describe('app registry', () => { let getElement; diff --git a/src/test/app.test.jsx b/src/test/app.test.jsx index cf39efd1d..8ec40d007 100644 --- a/src/test/app.test.jsx +++ b/src/test/app.test.jsx @@ -1,6 +1,6 @@ /* eslint-disable */ import React from 'react'; -import * as redux from 'redux'; +import { QueryClient, QueryClientProvider } from 'react-query'; import { Provider } from 'react-redux'; import { act, @@ -78,57 +78,18 @@ jest.mock('utils/hooks', () => { }; }); - -const configureStore = () => redux.createStore( - reducers, -); +const queryClient = new QueryClient(); let el; -let store; -let state; let retryLink; let inspector; -/** - * Simple wrapper for updating the top-level state variable, that also returns the new value - * @return {obj} - current redux store state - */ -const getState = () => { - state = store.getState(); - return state; -}; - -/** - * Object to be filled with resolve/reject functions for all controlled network comm channels - */ -const resolveFns = { -}; /** * Mock the api with jest functions that can be tested against. */ -const mockNetworkError = (reject) => () => reject(new Error({ - response: { status: ErrorStatuses.badRequest }, -})); - -const mockForbiddenError = (reject) => () => reject(new Error({ - response: { status: ErrorStatuses.forbidden }, -})); - - -const allCourses = [ - ...fakeData.courseRunData, - ...fakeData.entitlementData, -]; - -const { compileCourseRunData, compileEntitlementData } = fakeData; - -const initCourses = jest.fn(() => []); - -let initializeApp; - const mockApi = () => { api.initializeList = jest.fn(() => new Promise( - (resolve, reject) => { + (resolve) => { resolveFns.init = { success: () => { const data = { @@ -142,28 +103,24 @@ const mockApi = () => { }; /** - * load and configure the store, render the element, and populate the top-level state object + * Render the element and initialize React Query's QueryClientProvider. */ const renderEl = async () => { - store = configureStore(); el = await render( - + - + , ); - getState(); }; -const waitForEqual = async (valFn, expected, key) => waitFor(() => { - expect(valFn(), `${key} is expected to equal ${expected}`).toEqual(expected); -}); -const waitForRequestStatus = (key, status) => waitForEqual( - () => getState().requests[key].status, - status, - key, -); +const waitForRequestStatus = async (key, status) => { + await waitFor(() => { + const queryState = queryClient.getQueryState(key); + expect(queryState?.status).toEqual(status); + }); +}; const loadApp = async (courses) => { initCourses.mockReturnValue(courses.map(compileCourseRunData)); @@ -172,7 +129,7 @@ const loadApp = async (courses) => { await waitForRequestStatus(RequestKeys.initialize, RequestStates.pending); resolveFns.init.success(); await waitForRequestStatus(RequestKeys.initialize, RequestStates.completed); -} +}; const courseNames = [ 'course-name-1', @@ -196,7 +153,6 @@ describe('ESG app integration tests', () => { 'course-name-2', ]; const testCourse = async (index, tests) => { - await getState(); const cards = inspector.get.courseCards; const card = cards.at(index); const cardId = genCardId(index);