diff --git a/package-lock.json b/package-lock.json index e6a2f8aa15..34e848922d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,11 @@ "license": "AGPL-3.0", "dependencies": { "@edx/brand": "npm:@edx/brand-openedx@1.2.0", - "@edx/frontend-component-footer": "12.1.2", - "@edx/frontend-component-header": "4.4.4", + "@edx/frontend-component-footer": "12.2.1", + "@edx/frontend-component-header": "4.6.0", "@edx/frontend-lib-learning-assistant": "^1.14.0", "@edx/frontend-lib-special-exams": "2.23.2", - "@edx/frontend-platform": "4.6.0", + "@edx/frontend-platform": "5.0.0", "@edx/paragon": "20.46.0", "@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0", "@fortawesome/fontawesome-svg-core": "1.3.0", @@ -42,8 +42,8 @@ "react-dom": "17.0.2", "react-helmet": "6.1.0", "react-redux": "7.2.9", - "react-router": "5.2.1", - "react-router-dom": "5.3.0", + "react-router": "6.15.0", + "react-router-dom": "6.15.0", "react-share": "4.4.1", "redux": "4.1.2", "regenerator-runtime": "0.13.11", @@ -3181,75 +3181,75 @@ } }, "node_modules/@edx/frontend-component-footer": { - "version": "12.1.2", - "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.1.2.tgz", - "integrity": "sha512-f1lM1WTpdiD4vjrbE5pMC8J11ObWI9w7upBBk+xqP3Iv27+py9Sr4CJVhQVRwqvIYr6heurvv9UxECbZ0X3alg==", - "dependencies": { - "@fortawesome/fontawesome-svg-core": "6.4.0", - "@fortawesome/free-brands-svg-icons": "6.4.0", - "@fortawesome/free-regular-svg-icons": "6.4.0", - "@fortawesome/free-solid-svg-icons": "6.4.0", + "version": "12.2.1", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.2.1.tgz", + "integrity": "sha512-0ZeuFsnToS7h7qI4yXo6FKVz+c4vDyqP2nhPMR3xm3xgPOmHvf8KNL6ES/YGb+ptPYb64ZxT2iNBP6DY0wF3uQ==", + "dependencies": { + "@fortawesome/fontawesome-svg-core": "6.4.2", + "@fortawesome/free-brands-svg-icons": "6.4.2", + "@fortawesome/free-regular-svg-icons": "6.4.2", + "@fortawesome/free-solid-svg-icons": "6.4.2", "@fortawesome/react-fontawesome": "0.2.0" }, "peerDependencies": { - "@edx/frontend-platform": "^4.0.0", + "@edx/frontend-platform": "^4.0.0 || ^5.0.0", "prop-types": "^15.5.10", "react": "^16.9.0 || ^17.0.0", "react-dom": "^16.9.0 || ^17.0.0" } }, "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz", - "integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz", + "integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==", "hasInstallScript": true, "engines": { "node": ">=6" } }, "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz", - "integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz", + "integrity": "sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.4.0" + "@fortawesome/fontawesome-common-types": "6.4.2" }, "engines": { "node": ">=6" } }, "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz", - "integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.2.tgz", + "integrity": "sha512-LKOwJX0I7+mR/cvvf6qIiqcERbdnY+24zgpUSouySml+5w8B4BJOx8EhDR/FTKAu06W12fmUIcv6lzPSwYKGGg==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.4.0" + "@fortawesome/fontawesome-common-types": "6.4.2" }, "engines": { "node": ">=6" } }, "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz", - "integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.2.tgz", + "integrity": "sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.4.0" + "@fortawesome/fontawesome-common-types": "6.4.2" }, "engines": { "node": ">=6" } }, "node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz", - "integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz", + "integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.4.0" + "@fortawesome/fontawesome-common-types": "6.4.2" }, "engines": { "node": ">=6" @@ -3268,39 +3268,32 @@ } }, "node_modules/@edx/frontend-component-header": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.4.4.tgz", - "integrity": "sha512-7C2cr9A/5uIt7zOLtM650MT66zO2Q/O+AY1WufrdH2D/YiSoBTL+rFPPv39PHpDe5zZ8Wq0LyFLm/pUeTWDQIQ==", - "dependencies": { - "@edx/paragon": "20.45.5", - "@fortawesome/fontawesome-svg-core": "6.4.0", - "@fortawesome/free-brands-svg-icons": "6.4.0", - "@fortawesome/free-regular-svg-icons": "6.4.0", - "@fortawesome/free-solid-svg-icons": "6.4.0", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.6.0.tgz", + "integrity": "sha512-zZuMgHQWfFMTquVb4iL/iQMwKRRgts8CFFLyL8R6vQL1WfHd21hndhKii2kp9lBnIJgrilIfF79RsbImb5L0og==", + "dependencies": { + "@edx/paragon": "20.46.2", + "@fortawesome/fontawesome-svg-core": "6.4.2", + "@fortawesome/free-brands-svg-icons": "6.4.2", + "@fortawesome/free-regular-svg-icons": "6.4.2", + "@fortawesome/free-solid-svg-icons": "6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", - "@reduxjs/toolkit": "1.9.5", "axios-mock-adapter": "1.21.5", "babel-polyfill": "6.26.0", - "classnames": "2.3.2", - "lodash": "4.17.21", - "react-redux": "7.2.9", "react-responsive": "8.2.0", - "react-router-dom": "5.3.4", - "react-transition-group": "4.4.5", - "rosie": "2.1.0", - "timeago.js": "4.0.2" + "react-transition-group": "4.4.5" }, "peerDependencies": { - "@edx/frontend-platform": "^4.0.0", + "@edx/frontend-platform": "^4.0.0 || ^5.0.0", "prop-types": "^15.5.10", "react": "^16.9.0 || ^17.0.0", "react-dom": "^16.9.0 || ^17.0.0" } }, "node_modules/@edx/frontend-component-header/node_modules/@edx/paragon": { - "version": "20.45.5", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.45.5.tgz", - "integrity": "sha512-7GsGPKyxtjFo3Xnj+uQ4vx/Khz7S6srHe8MqcsYCMx2mJ8fulPN2JFm84m+0o1CSwHaL469wBPONI4KCa+vfrA==", + "version": "20.46.2", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.46.2.tgz", + "integrity": "sha512-px+KS/BV1CbiMKgfVgUofyjJi4CHUCUOLRukJbT66VPPqWP4Xon5Rns6uohoratPXMg2kNN46v2L8wIwqKQ4Lw==", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -3347,57 +3340,57 @@ } }, "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz", - "integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz", + "integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==", "hasInstallScript": true, "engines": { "node": ">=6" } }, "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz", - "integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz", + "integrity": "sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.4.0" + "@fortawesome/fontawesome-common-types": "6.4.2" }, "engines": { "node": ">=6" } }, "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz", - "integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.2.tgz", + "integrity": "sha512-LKOwJX0I7+mR/cvvf6qIiqcERbdnY+24zgpUSouySml+5w8B4BJOx8EhDR/FTKAu06W12fmUIcv6lzPSwYKGGg==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.4.0" + "@fortawesome/fontawesome-common-types": "6.4.2" }, "engines": { "node": ">=6" } }, "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz", - "integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.2.tgz", + "integrity": "sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.4.0" + "@fortawesome/fontawesome-common-types": "6.4.2" }, "engines": { "node": ">=6" } }, "node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz", - "integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz", + "integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.4.0" + "@fortawesome/fontawesome-common-types": "6.4.2" }, "engines": { "node": ">=6" @@ -3415,29 +3408,6 @@ "react": ">=16.3" } }, - "node_modules/@edx/frontend-component-header/node_modules/@reduxjs/toolkit": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz", - "integrity": "sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==", - "dependencies": { - "immer": "^9.0.21", - "redux": "^4.2.1", - "redux-thunk": "^2.4.2", - "reselect": "^4.1.8" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18", - "react-redux": "^7.2.1 || ^8.0.2" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@edx/frontend-component-header/node_modules/axios-mock-adapter": { "version": "1.21.5", "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.21.5.tgz", @@ -3476,24 +3446,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@edx/frontend-component-header/node_modules/history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, "node_modules/@edx/frontend-component-header/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -3505,63 +3457,6 @@ "node": ">=10" } }, - "node_modules/@edx/frontend-component-header/node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/@edx/frontend-component-header/node_modules/react-router": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", - "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "hoist-non-react-statics": "^3.1.0", - "loose-envify": "^1.3.1", - "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.2", - "react-is": "^16.6.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - }, - "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/react-router-dom": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", - "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.3.4", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - }, - "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/@edx/frontend-component-header/node_modules/redux": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, "node_modules/@edx/frontend-lib-learning-assistant": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/@edx/frontend-lib-learning-assistant/-/frontend-lib-learning-assistant-1.14.0.tgz", @@ -3728,9 +3623,9 @@ } }, "node_modules/@edx/frontend-platform": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-4.6.0.tgz", - "integrity": "sha512-NZ1I3BgUZl7bqvDwSnnL+LxqZOdOUGZU55KiwvknqiKU8RS5Lx9tc4arp+NcX1u58xy/Xbinv+mriSO6PPxQNQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-5.0.0.tgz", + "integrity": "sha512-DD9/B4rnC3BKPiWlbEFF1JIYFbWC6vUBKTyN8sf4khi4DNhhWhsobk+iNeCWNzF9UgCPRbniIqesdV1F9NXNZw==", "dependencies": { "@cospired/i18n-iso-languages": "4.1.0", "@formatjs/intl-pluralrules": "4.3.3", @@ -3757,13 +3652,13 @@ "transifex-utils.js": "i18n/scripts/transifex-utils.js" }, "peerDependencies": { - "@edx/frontend-build": ">= 8.1.0", + "@edx/frontend-build": ">= 8.1.0 || ^12.9.0-alpha.1", "@edx/paragon": ">= 10.0.0 < 21.0.0", "prop-types": "^15.7.2", "react": "^16.9.0 || ^17.0.0", "react-dom": "^16.9.0 || ^17.0.0", "react-redux": "^7.1.1", - "react-router-dom": "^5.0.1", + "react-router-dom": "^6.0.0", "redux": "^4.0.4" } }, @@ -3916,6 +3811,46 @@ "react": "^16.9.0 || ^17.0.0" } }, + "node_modules/@edx/react-unit-test-utils/node_modules/@edx/frontend-platform": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-4.6.0.tgz", + "integrity": "sha512-NZ1I3BgUZl7bqvDwSnnL+LxqZOdOUGZU55KiwvknqiKU8RS5Lx9tc4arp+NcX1u58xy/Xbinv+mriSO6PPxQNQ==", + "dependencies": { + "@cospired/i18n-iso-languages": "4.1.0", + "@formatjs/intl-pluralrules": "4.3.3", + "@formatjs/intl-relativetimeformat": "10.0.1", + "axios": "0.27.2", + "axios-cache-interceptor": "0.10.7", + "form-urlencoded": "4.1.4", + "glob": "7.2.3", + "history": "4.10.1", + "i18n-iso-countries": "4.3.1", + "jwt-decode": "3.1.2", + "localforage": "1.10.0", + "localforage-memoryStorageDriver": "0.9.2", + "lodash.camelcase": "4.3.0", + "lodash.memoize": "4.1.2", + "lodash.merge": "4.6.2", + "lodash.snakecase": "4.1.1", + "pubsub-js": "1.9.4", + "react-intl": "^5.25.0", + "universal-cookie": "4.0.4" + }, + "bin": { + "intl-imports.js": "i18n/scripts/intl-imports.js", + "transifex-utils.js": "i18n/scripts/transifex-utils.js" + }, + "peerDependencies": { + "@edx/frontend-build": ">= 8.1.0", + "@edx/paragon": ">= 10.0.0 < 21.0.0", + "prop-types": "^15.7.2", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-redux": "^7.1.1", + "react-router-dom": "^5.0.1", + "redux": "^4.0.4" + } + }, "node_modules/@edx/react-unit-test-utils/node_modules/@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -3963,6 +3898,15 @@ } } }, + "node_modules/@edx/react-unit-test-utils/node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "node_modules/@edx/react-unit-test-utils/node_modules/core-js": { "version": "3.6.5", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", @@ -3974,6 +3918,78 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/@edx/react-unit-test-utils/node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "peer": true + }, + "node_modules/@edx/react-unit-test-utils/node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "peer": true, + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "peer": true + }, + "node_modules/@edx/react-unit-test-utils/node_modules/react-router": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/@edx/react-unit-test-utils/node_modules/react-router-dom": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.4", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, "node_modules/@edx/reactifex": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@edx/reactifex/-/reactifex-2.2.0.tgz", @@ -5857,6 +5873,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.8.0.tgz", + "integrity": "sha512-mrfKqIHnSZRyIzBcanNJmVQELTnX+qagEDlcKO90RgRBVOZGSGvZKeDihTRfWcqoDn5N/NkUcwWTccnpN18Tfg==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@restart/context": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz", @@ -7475,9 +7499,9 @@ } }, "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz", + "integrity": "sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==", "peer": true, "dependencies": { "follow-redirects": "^1.15.0", @@ -16649,9 +16673,9 @@ } }, "node_modules/jquery": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz", - "integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", "peer": true }, "node_modules/js-base64": { @@ -17898,20 +17922,6 @@ "node": ">=4" } }, - "node_modules/mini-create-react-context": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", - "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dependencies": { - "@babel/runtime": "^7.12.1", - "tiny-warning": "^1.0.3" - }, - "peerDependencies": { - "prop-types": "^15.0.0", - "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/mini-css-extract-plugin": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.6.2.tgz", @@ -20640,86 +20650,35 @@ } }, "node_modules/react-router": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz", - "integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.15.0.tgz", + "integrity": "sha512-NIytlzvzLwJkCQj2HLefmeakxxWHWAP+02EGqWEZy+DgfHHKQMUoBBjUQLOtFInBMhWtb3hiUy6MfFgwLjXhqg==", "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "hoist-non-react-statics": "^3.1.0", - "loose-envify": "^1.3.1", - "mini-create-react-context": "^0.4.0", - "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.2", - "react-is": "^16.6.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" + "@remix-run/router": "1.8.0" + }, + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=15" + "react": ">=16.8" } }, "node_modules/react-router-dom": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz", - "integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.15.0.tgz", + "integrity": "sha512-aR42t0fs7brintwBGAv2+mGlCtgtFQeOzK0BM1/OiqEzRejOZtpMZepvgkscpMUnKb8YO84G7s3LsHnnDNonbQ==", "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.2.1", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" + "@remix-run/router": "1.8.0", + "react-router": "6.15.0" + }, + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { - "react": ">=15" + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/react-router-dom/node_modules/history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" - } - }, - "node_modules/react-router/node_modules/history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" - } - }, - "node_modules/react-router/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, - "node_modules/react-router/node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/react-router/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, "node_modules/react-shallow-renderer": { "version": "16.15.0", "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", @@ -21349,6 +21308,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/rosie/-/rosie-2.1.0.tgz", "integrity": "sha512-Dbzdc+prLXZuB/suRptDnBUY29SdGvND3bLg6cll8n7PNqzuyCxSlRfrkn8PqjS9n4QVsiM7RCvxCkKAkTQRjA==", + "dev": true, "engines": { "node": ">=10" } @@ -23586,11 +23546,6 @@ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" }, - "node_modules/timeago.js": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/timeago.js/-/timeago.js-4.0.2.tgz", - "integrity": "sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==" - }, "node_modules/timers-ext": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", diff --git a/package.json b/package.json index b4aa0b2107..6a969ff262 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,11 @@ }, "dependencies": { "@edx/brand": "npm:@edx/brand-openedx@1.2.0", - "@edx/frontend-component-footer": "12.1.2", - "@edx/frontend-component-header": "4.4.4", + "@edx/frontend-component-footer": "12.2.1", + "@edx/frontend-component-header": "4.6.0", "@edx/frontend-lib-learning-assistant": "^1.14.0", "@edx/frontend-lib-special-exams": "2.23.2", - "@edx/frontend-platform": "4.6.0", + "@edx/frontend-platform": "5.0.0", "@edx/paragon": "20.46.0", "@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0", "@fortawesome/fontawesome-svg-core": "1.3.0", @@ -55,8 +55,8 @@ "react-dom": "17.0.2", "react-helmet": "6.1.0", "react-redux": "7.2.9", - "react-router": "5.2.1", - "react-router-dom": "5.3.0", + "react-router": "6.15.0", + "react-router-dom": "6.15.0", "react-share": "4.4.1", "redux": "4.1.2", "regenerator-runtime": "0.13.11", diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000000..ac10aa3044 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,33 @@ +export const DECODE_ROUTES = { + ACCESS_DENIED: '/course/:courseId/access-denied', + HOME: '/course/:courseId/home', + LIVE: '/course/:courseId/live', + DATES: '/course/:courseId/dates', + DISCUSSION: '/course/:courseId/discussion/:path/*', + PROGRESS: [ + '/course/:courseId/progress/:targetUserId/', + '/course/:courseId/progress', + ], + COURSE_END: '/course/:courseId/course-end', + COURSEWARE: [ + '/course/:courseId/:sequenceId/:unitId', + '/course/:courseId/:sequenceId', + '/course/:courseId', + ], + REDIRECT_HOME: 'home/:courseId', + REDIRECT_SURVEY: 'survey/:courseId', +}; + +export const ROUTES = { + UNSUBSCRIBE: '/goal-unsubscribe/:token', + REDIRECT: '/redirect/*', + DASHBOARD: 'dashboard', + CONSENT: 'consent', +}; + +export const REDIRECT_MODES = { + DASHBOARD_REDIRECT: 'dashboard-redirect', + CONSENT_REDIRECT: 'consent-redirect', + HOME_REDIRECT: 'home-redirect', + SURVEY_REDIRECT: 'survey-redirect', +}; diff --git a/src/course-home/dates-tab/DatesTab.test.jsx b/src/course-home/dates-tab/DatesTab.test.jsx index dc03dcf046..329e23f84c 100644 --- a/src/course-home/dates-tab/DatesTab.test.jsx +++ b/src/course-home/dates-tab/DatesTab.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Route } from 'react-router'; +import { Routes, Route } from 'react-router-dom'; import MockAdapter from 'axios-mock-adapter'; import { Factory } from 'rosie'; import { getConfig, history } from '@edx/frontend-platform'; @@ -32,11 +32,16 @@ describe('DatesTab', () => { component = ( - - - - - + + + + + )} + /> + ); diff --git a/src/course-home/discussion-tab/DiscussionTab.jsx b/src/course-home/discussion-tab/DiscussionTab.jsx index 0bbae8be3c..06ee97cc7c 100644 --- a/src/course-home/discussion-tab/DiscussionTab.jsx +++ b/src/course-home/discussion-tab/DiscussionTab.jsx @@ -2,21 +2,20 @@ import { getConfig } from '@edx/frontend-platform'; import { injectIntl } from '@edx/frontend-platform/i18n'; import React, { useState } from 'react'; import { useSelector } from 'react-redux'; -import { generatePath, useHistory } from 'react-router'; -import { useParams } from 'react-router-dom'; +import { useParams, generatePath, useNavigate } from 'react-router-dom'; import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks'; const DiscussionTab = () => { const { courseId } = useSelector(state => state.courseHome); const { path } = useParams(); const [originalPath] = useState(path); - const history = useHistory(); + const navigate = useNavigate(); const [, iFrameHeight] = useIFrameHeight(); useIFramePluginEvents({ 'discussions.navigate': (payload) => { const basePath = generatePath('/course/:courseId/discussion', { courseId }); - history.push(`${basePath}/${payload.path}`); + navigate(`${basePath}/${payload.path}`); }, }); const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`; diff --git a/src/course-home/discussion-tab/DiscussionTab.test.jsx b/src/course-home/discussion-tab/DiscussionTab.test.jsx index 08672c5f80..9cd6b9e060 100644 --- a/src/course-home/discussion-tab/DiscussionTab.test.jsx +++ b/src/course-home/discussion-tab/DiscussionTab.test.jsx @@ -4,7 +4,7 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { render } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import React from 'react'; -import { Route } from 'react-router'; +import { Route, Routes } from 'react-router-dom'; import { Factory } from 'rosie'; import { UserMessagesProvider } from '../../generic/user-messages'; import { @@ -30,11 +30,16 @@ describe('DiscussionTab', () => { component = ( - - - - - + + + + + )} + /> + ); diff --git a/src/course-home/goal-unsubscribe/GoalUnsubscribe.test.jsx b/src/course-home/goal-unsubscribe/GoalUnsubscribe.test.jsx index dc858355be..b5e90a4e4d 100644 --- a/src/course-home/goal-unsubscribe/GoalUnsubscribe.test.jsx +++ b/src/course-home/goal-unsubscribe/GoalUnsubscribe.test.jsx @@ -1,7 +1,9 @@ import React from 'react'; -import { Route } from 'react-router'; +import { + MemoryRouter, Route, Routes, +} from 'react-router-dom'; import MockAdapter from 'axios-mock-adapter'; -import { getConfig, history } from '@edx/frontend-platform'; +import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; import { render, screen } from '@testing-library/react'; @@ -24,13 +26,16 @@ describe('GoalUnsubscribe', () => { axiosMock = new MockAdapter(getAuthenticatedHttpClient()); store = initializeStore(); component = ( - + - + + + } /> + + ); - history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url }); it('starts with a spinner', () => { diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx index 072f0d04c6..a2ce8101ea 100644 --- a/src/course-home/outline-tab/OutlineTab.jsx +++ b/src/course-home/outline-tab/OutlineTab.jsx @@ -1,9 +1,8 @@ import React, { useEffect, useState } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { history } from '@edx/frontend-platform'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button } from '@edx/paragon'; import { AlertList } from '../../generic/user-messages'; @@ -67,6 +66,7 @@ const OutlineTab = ({ intl }) => { } = useModel('coursewareMeta', courseId); const [expandAll, setExpandAll] = useState(false); + const navigate = useNavigate(); const eventProperties = { org_key: org, @@ -115,8 +115,10 @@ const OutlineTab = ({ intl }) => { // Deleting the course_start query param as it only needs to be set once // whenever passed in query params. currentParams.delete('start_course'); - history.replace({ - search: currentParams.toString(), + navigate({ + pathname: location.pathname, + search: `?${currentParams.toString()}`, + replace: true, }); } }, [location.search]); diff --git a/src/courseware/CoursewareContainer.jsx b/src/courseware/CoursewareContainer.jsx index 0bc792b580..8929419075 100644 --- a/src/courseware/CoursewareContainer.jsx +++ b/src/courseware/CoursewareContainer.jsx @@ -1,7 +1,6 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { history } from '@edx/frontend-platform'; import { createSelector } from '@reduxjs/toolkit'; import { defaultMemoize as memoize } from 'reselect'; @@ -17,45 +16,46 @@ import { TabPage } from '../tab-page'; import Course from './course'; import { handleNextSectionCelebration } from './course/celebration'; +import withParamsAndNavigation from './utils'; // Look at where this is called in componentDidUpdate for more info about its usage -const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => { +const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => { if (courseStatus === 'loaded' && !sequenceId) { // Note that getResumeBlock is just an API call, not a redux thunk. getResumeBlock(courseId).then((data) => { // This is a replace because we don't want this change saved in the browser's history. if (data.sectionId && data.unitId) { - history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`); + navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true }); } else if (firstSequenceId) { - history.replace(`/course/${courseId}/${firstSequenceId}`); + navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true }); } }); } }); // Look at where this is called in componentDidUpdate for more info about its usage -const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => { +const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => { if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) { - history.replace(`/course/${courseId}/${unitId}`); + navigate(`/course/${courseId}/${unitId}`, { replace: true }); } }); // Look at where this is called in componentDidUpdate for more info about its usage -const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => { +const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => { if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) { // If the section is non-empty, redirect to its first sequence. if (section.sequenceIds && section.sequenceIds[0]) { - history.replace(`/course/${courseId}/${section.sequenceIds[0]}`); + navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true }); // Otherwise, just go to the course root, letting the resume redirect take care of things. } else { - history.replace(`/course/${courseId}`); + navigate(`/course/${courseId}`, { replace: true }); } } }); // Look at where this is called in componentDidUpdate for more info about its usage const checkUnitToSequenceUnitRedirect = memoize( - (courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => { + (courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => { if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) { if (sequenceMightBeUnit) { // If the sequence failed to load as a sequence, but it is marked as a possible unit, then @@ -64,60 +64,62 @@ const checkUnitToSequenceUnitRedirect = memoize( getSequenceForUnitDeprecated(courseId, unitId).then( parentId => { if (parentId) { - history.replace(`/course/${courseId}/${parentId}/${unitId}`); + navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true }); } else { - history.replace(`/course/${courseId}`); + navigate(`/course/${courseId}`, { replace: true }); } }, () => { // error case - history.replace(`/course/${courseId}`); + navigate(`/course/${courseId}`, { replace: true }); }, ); } else { // Invalid sequence that isn't a unit either. Redirect up to main course. - history.replace(`/course/${courseId}`); + navigate(`/course/${courseId}`, { replace: true }); } } }, ); // Look at where this is called in componentDidUpdate for more info about its usage -const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => { +const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => { if (sequenceStatus === 'loaded' && sequence.id && !unitId) { if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) { const nextUnitId = sequence.unitIds[sequence.activeUnitIndex]; // This is a replace because we don't want this change saved in the browser's history. - history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`); + navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true }); } } }); // Look at where this is called in componentDidUpdate for more info about its usage -const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => { - if (sequenceStatus !== 'loaded' || !sequence.id) { - return; - } +const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize( + (courseId, sequenceStatus, sequence, unitId, navigate) => { + if (sequenceStatus !== 'loaded' || !sequence.id) { + return; + } - const hasUnits = sequence.unitIds?.length > 0; + const hasUnits = sequence.unitIds?.length > 0; - if (unitId === 'first') { - if (hasUnits) { - const firstUnitId = sequence.unitIds[0]; - history.replace(`/course/${courseId}/${sequence.id}/${firstUnitId}`); - } else { + if (unitId === 'first') { + if (hasUnits) { + const firstUnitId = sequence.unitIds[0]; + navigate(`/course/${courseId}/${sequence.id}/${firstUnitId}`, { replace: true }); + } else { // No units... go to general sequence page - history.replace(`/course/${courseId}/${sequence.id}`); - } - } else if (unitId === 'last') { - if (hasUnits) { - const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1]; - history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`); - } else { + navigate(`/course/${courseId}/${sequence.id}`, { replace: true }); + } + } else if (unitId === 'last') { + if (hasUnits) { + const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1]; + navigate(`/course/${courseId}/${sequence.id}/${lastUnitId}`, { replace: true }); + } else { // No units... go to general sequence page - history.replace(`/course/${courseId}/${sequence.id}`); + navigate(`/course/${courseId}/${sequence.id}`, { replace: true }); + } } - } -}); + }, +); class CoursewareContainer extends Component { checkSaveSequencePosition = memoize((unitId) => { @@ -145,12 +147,8 @@ class CoursewareContainer extends Component { componentDidMount() { const { - match: { - params: { - courseId: routeCourseId, - sequenceId: routeSequenceId, - }, - }, + routeCourseId, + routeSequenceId, } = this.props; // Load data whenever the course or sequence ID changes. this.checkFetchCourse(routeCourseId); @@ -167,13 +165,10 @@ class CoursewareContainer extends Component { sequence, firstSequenceId, sectionViaSequenceId, - match: { - params: { - courseId: routeCourseId, - sequenceId: routeSequenceId, - unitId: routeUnitId, - }, - }, + routeCourseId, + routeSequenceId, + routeUnitId, + navigate, } = this.props; // Load data whenever the course or sequence ID changes. @@ -202,7 +197,7 @@ class CoursewareContainer extends Component { // Check resume redirect: // /course/:courseId -> /course/:courseId/:sequenceId/:unitId // based on sequence/unit where user was last active. - checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId); + checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate); // Check section-unit to unit redirect: // /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId @@ -215,42 +210,40 @@ class CoursewareContainer extends Component { // otherwise, we could get stuck in a redirect loop, since a sequence that failed to load // would endlessly redirect to itself through `checkSectionUnitToUnitRedirect` // and `checkUnitToSequenceUnitRedirect`. - checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId); + checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate); // Check section to sequence redirect: // /course/:courseId/:sectionId -> /course/:courseId/:sequenceId // by redirecting to the first sequence within the section. - checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId); + checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate); // Check unit to sequence-unit redirect: // /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId // by filling in the ID of the parent sequence of :unitId. checkUnitToSequenceUnitRedirect(( - courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId + courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, + sequenceId, sectionViaSequenceId, routeUnitId, navigate )); // Check sequence to sequence-unit redirect: // /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId // by filling in the ID the most-recently-active unit in the sequence, OR // the ID of the first unit the sequence if none is active. - checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId); + checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate); // Check sequence-unit marker to sequence-unit redirect: // /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId // /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId // by filling in the ID the first or last unit in the sequence. // "Sequence unit marker" is an invented term used only in this component. - checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId); + checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate); } handleUnitNavigationClick = () => { const { - courseId, sequenceId, - match: { - params: { - unitId: routeUnitId, - }, - }, + courseId, + sequenceId, + routeUnitId, } = this.props; this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId); @@ -279,11 +272,7 @@ class CoursewareContainer extends Component { courseStatus, courseId, sequenceId, - match: { - params: { - unitId: routeUnitId, - }, - }, + routeUnitId, } = this.props; return ( @@ -326,13 +315,9 @@ const courseShape = PropTypes.shape({ }); CoursewareContainer.propTypes = { - match: PropTypes.shape({ - params: PropTypes.shape({ - courseId: PropTypes.string.isRequired, - sequenceId: PropTypes.string, - unitId: PropTypes.string, - }).isRequired, - }).isRequired, + routeCourseId: PropTypes.string.isRequired, + routeSequenceId: PropTypes.string, + routeUnitId: PropTypes.string, courseId: PropTypes.string, sequenceId: PropTypes.string, firstSequenceId: PropTypes.string, @@ -348,11 +333,14 @@ CoursewareContainer.propTypes = { checkBlockCompletion: PropTypes.func.isRequired, fetchCourse: PropTypes.func.isRequired, fetchSequence: PropTypes.func.isRequired, + navigate: PropTypes.func.isRequired, }; CoursewareContainer.defaultProps = { courseId: null, sequenceId: null, + routeSequenceId: null, + routeUnitId: null, firstSequenceId: null, nextSequence: null, previousSequence: null, @@ -467,4 +455,4 @@ export default connect(mapStateToProps, { saveSequencePosition, fetchCourse, fetchSequence, -})(CoursewareContainer); +})(withParamsAndNavigation(CoursewareContainer)); diff --git a/src/courseware/CoursewareContainer.test.jsx b/src/courseware/CoursewareContainer.test.jsx index 5df0deebb8..66bc28f9e6 100644 --- a/src/courseware/CoursewareContainer.test.jsx +++ b/src/courseware/CoursewareContainer.test.jsx @@ -5,13 +5,16 @@ import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom'; import '@testing-library/jest-dom/extend-expect'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import { Route, Switch } from 'react-router'; +import { + BrowserRouter, MemoryRouter, Route, Routes, +} from 'react-router-dom'; import { Factory } from 'rosie'; import MockAdapter from 'axios-mock-adapter'; import { UserMessagesProvider } from '../generic/user-messages'; import tabMessages from '../tab-page/messages'; import { initializeMockApp, waitFor } from '../setupTest'; +import { DECODE_ROUTES } from '../constants'; import CoursewareContainer from './CoursewareContainer'; import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory'; @@ -80,18 +83,16 @@ describe('CoursewareContainer', () => { store = initializeStore(); component = ( - + - - - + + {DECODE_ROUTES.COURSEWARE.map((route) => ( + } + /> + ))} + ); @@ -151,7 +152,7 @@ describe('CoursewareContainer', () => { } async function loadContainer() { - const { container } = render(component); + const { container } = render({component}); // Wait for the page spinner to be removed, such that we can wait for our main // content to load before making any assertions. await waitForElementToBeRemoved(screen.getByRole('status')); @@ -160,7 +161,7 @@ describe('CoursewareContainer', () => { it('should initialize to show a spinner', () => { history.push('/course/abc123'); - render(component); + render({component}); const spinner = screen.getByRole('status'); diff --git a/src/courseware/CoursewareRedirectLandingPage.jsx b/src/courseware/CoursewareRedirectLandingPage.jsx index c3f965c5e8..1d90f50b2e 100644 --- a/src/courseware/CoursewareRedirectLandingPage.jsx +++ b/src/courseware/CoursewareRedirectLandingPage.jsx @@ -1,56 +1,44 @@ import React from 'react'; -import { Switch, useRouteMatch } from 'react-router'; -import { getConfig } from '@edx/frontend-platform'; +import { Routes, Route } from 'react-router-dom'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { PageRoute } from '@edx/frontend-platform/react'; +import { PageWrap } from '@edx/frontend-platform/react'; -import queryString from 'query-string'; import PageLoading from '../generic/PageLoading'; import DecodePageRoute from '../decode-page-route'; +import { DECODE_ROUTES, REDIRECT_MODES, ROUTES } from '../constants'; +import RedirectPage from './RedirectPage'; -const CoursewareRedirectLandingPage = () => { - const { path } = useRouteMatch(); - return ( -
- - )} +const CoursewareRedirectLandingPage = () => ( +
+ + )} + /> - - { - global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`); - }} - /> - { - global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`); - }} - /> - { - const { consentPath } = queryString.parse(location.search); - global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`); - }} - /> - { - global.location.assign(`/course/${match.params.courseId}/home`); - }} - /> - -
- ); -}; + + } + /> + } + /> + } + /> + } + /> + +
+); export default CoursewareRedirectLandingPage; diff --git a/src/courseware/CoursewareRedirectLandingPage.test.jsx b/src/courseware/CoursewareRedirectLandingPage.test.jsx index 08409380ea..84e39e5666 100644 --- a/src/courseware/CoursewareRedirectLandingPage.test.jsx +++ b/src/courseware/CoursewareRedirectLandingPage.test.jsx @@ -1,19 +1,12 @@ import React from 'react'; -import { Router } from 'react-router'; -import { createMemoryHistory } from 'history'; +import { MemoryRouter as Router } from 'react-router-dom'; import { render, initializeMockApp } from '../setupTest'; import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage'; const redirectUrl = jest.fn(); jest.mock('@edx/frontend-platform/analytics'); - -jest.mock('react-router', () => ({ - ...jest.requireActual('react-router'), - useRouteMatch: () => ({ - path: '/redirect', - }), -})); +jest.mock('../decode-page-route', () => jest.fn(({ children }) =>
{children}
)); describe('CoursewareRedirectLandingPage', () => { beforeEach(async () => { @@ -23,12 +16,8 @@ describe('CoursewareRedirectLandingPage', () => { }); it('Redirects to correct consent URL', () => { - const history = createMemoryHistory({ - initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'], - }); - render( - + , ); @@ -37,12 +26,8 @@ describe('CoursewareRedirectLandingPage', () => { }); it('Redirects to correct consent URL', () => { - const history = createMemoryHistory({ - initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'], - }); - render( - + , ); diff --git a/src/courseware/RedirectPage.jsx b/src/courseware/RedirectPage.jsx new file mode 100644 index 0000000000..f1189a47fe --- /dev/null +++ b/src/courseware/RedirectPage.jsx @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import { + generatePath, useParams, useLocation, +} from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; + +import queryString from 'query-string'; +import { REDIRECT_MODES } from '../constants'; + +const RedirectPage = ({ + pattern, mode, +}) => { + const { courseId } = useParams(); + const location = useLocation(); + const { consentPath } = queryString.parse(location?.search); + + const BASE_URL = getConfig().LMS_BASE_URL; + + switch (mode) { + case REDIRECT_MODES.DASHBOARD_REDIRECT: + global.location.assign(`${BASE_URL}${pattern}${location?.search}`); + break; + case REDIRECT_MODES.CONSENT_REDIRECT: + global.location.assign(`${BASE_URL}${consentPath}`); + break; + case REDIRECT_MODES.HOME_REDIRECT: + global.location.assign(generatePath(pattern, { courseId })); + break; + default: + global.location.assign(`${BASE_URL}${generatePath(pattern, { courseId })}`); + } + + return null; +}; + +RedirectPage.propTypes = { + pattern: PropTypes.string, + mode: PropTypes.string.isRequired, +}; + +RedirectPage.defaultProps = { + pattern: null, +}; + +export default RedirectPage; diff --git a/src/courseware/RedirectPage.test.jsx b/src/courseware/RedirectPage.test.jsx new file mode 100644 index 0000000000..604e6f76a9 --- /dev/null +++ b/src/courseware/RedirectPage.test.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; + +import RedirectPage from './RedirectPage'; +import { REDIRECT_MODES } from '../constants'; + +const BASE_URL = getConfig().LMS_BASE_URL; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + courseId: 'course-id-123', + }), + useLocation: () => ({ + search: '?consentPath=/some-path', + }), +})); + +describe('RedirectPage component', () => { + beforeEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: { assign: jest.fn() }, + }); + jest.clearAllMocks(); + }); + + it('should handle DASHBOARD_REDIRECT correctly', () => { + render( + + + , + ); + + expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/dashboard?consentPath=/some-path`); + }); + + it('should handle CONSENT_REDIRECT correctly', () => { + render( + + + , + ); + + expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/some-path`); + }); + + it('should handle HOME_REDIRECT correctly', () => { + render( + + + , + ); + + expect(global.location.assign).toHaveBeenCalledWith('/course/course-id-123/home'); + }); + + it('should handle the default case correctly', () => { + render( + + + , + ); + + expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/default/course-id-123`); + }); +}); diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx index 6362c0dcdb..d2c60d8fbe 100644 --- a/src/courseware/course/Course.test.jsx +++ b/src/courseware/course/Course.test.jsx @@ -15,6 +15,10 @@ import { executeThunk } from '../../utils'; import * as thunks from '../data/thunks'; jest.mock('@edx/frontend-platform/analytics'); +jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({ + ...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'), + checkExamEntry: () => jest.fn(), +})); const recordFirstSectionCelebration = jest.fn(); // eslint-disable-next-line no-import-assign @@ -65,11 +69,11 @@ describe('Course', () => { const [firstSequenceId] = Object.keys(state.models.sequences); mockData.sequenceId = firstSequenceId; - await render(, { store: testStore }); + await render(, { store: testStore, wrapWithRouter: true }); }; it('loads learning sequence', async () => { - render(); + render(, { wrapWithRouter: true }); expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument(); expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument(); @@ -102,7 +106,7 @@ describe('Course', () => { }; // Set up LocalStorage for testing. handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId); - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); const firstSectionCelebrationModal = screen.getByRole('dialog'); expect(firstSectionCelebrationModal).toBeInTheDocument(); @@ -120,7 +124,7 @@ describe('Course', () => { sequenceId, unitId: Object.values(models.units)[0].id, }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); const weeklyGoalCelebrationModal = screen.getByRole('dialog'); expect(weeklyGoalCelebrationModal).toBeInTheDocument(); @@ -128,7 +132,7 @@ describe('Course', () => { }); it('displays notification trigger and toggles active class on click', async () => { - render(); + render(, { wrapWithRouter: true }); const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i }); expect(notificationTrigger).toBeInTheDocument(); @@ -181,7 +185,7 @@ describe('Course', () => { it('handles click to open/close notification tray', async () => { sessionStorage.clear(); - render(); + render(, { wrapWithRouter: true }); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"'); const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i }); expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none'); @@ -192,7 +196,7 @@ describe('Course', () => { it('handles reload persisting notification tray status', async () => { sessionStorage.clear(); - render(); + render(, { wrapWithRouter: true }); const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i }); fireEvent.click(notificationShowButton); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"'); @@ -216,7 +220,7 @@ describe('Course', () => { // set sessionStorage for a different course before rendering Course sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"'); - render(); + render(, { wrapWithRouter: true }); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"'); const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i }); fireEvent.click(notificationShowButton); @@ -244,7 +248,7 @@ describe('Course', () => { sequenceId, unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests. }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); loadUnit(); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); @@ -276,7 +280,7 @@ describe('Course', () => { previousSequenceHandler, unitNavigationHandler, }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); loadUnit(); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); @@ -305,7 +309,7 @@ describe('Course', () => { courseId: courseMetadata.id, sequenceId: sequenceBlocks[0].id, }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument()); }); @@ -339,7 +343,7 @@ describe('Course', () => { courseId: testCourseMetadata.id, sequenceId: sequenceBlocks[0].id, }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument()); }); @@ -373,7 +377,7 @@ describe('Course', () => { courseId: testCourseMetadata.id, sequenceId: sequenceBlocks[0].id, }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument()); }); }); diff --git a/src/courseware/course/CourseBreadcrumbs.jsx b/src/courseware/course/CourseBreadcrumbs.jsx index f0a05eea7b..a49906a894 100644 --- a/src/courseware/course/CourseBreadcrumbs.jsx +++ b/src/courseware/course/CourseBreadcrumbs.jsx @@ -159,6 +159,7 @@ const CourseBreadcrumbs = ({ ({ Provider: ({ children }) => children, useSelector: () => 'loaded', })); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + Link: jest.fn().mockImplementation(({ to, children }) => ( + {children} + )), +})); useModels.mockImplementation((name) => { if (name === 'sections') { diff --git a/src/courseware/course/JumpNavMenuItem.jsx b/src/courseware/course/JumpNavMenuItem.jsx index 2975375163..a5358480d7 100644 --- a/src/courseware/course/JumpNavMenuItem.jsx +++ b/src/courseware/course/JumpNavMenuItem.jsx @@ -1,12 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { history } from '@edx/frontend-platform'; import { Dropdown } from '@edx/paragon'; import { sendTrackingLogEvent, sendTrackEvent, } from '@edx/frontend-platform/analytics'; +import { useNavigate } from 'react-router-dom'; const JumpNavMenuItem = ({ title, @@ -17,6 +17,8 @@ const JumpNavMenuItem = ({ isDefault, onClick, }) => { + const navigate = useNavigate(); + function logEvent(targetUrl) { const eventName = 'edx.ui.lms.jump_nav.selected'; const payload = { @@ -38,7 +40,7 @@ const JumpNavMenuItem = ({ function handleClick(e) { const url = destinationUrl(); logEvent(url); - history.push(url); + navigate(url); if (onClick) { onClick(e); } } diff --git a/src/courseware/course/JumpNavMenuItem.test.jsx b/src/courseware/course/JumpNavMenuItem.test.jsx index 687611dbdc..8a92661fa4 100644 --- a/src/courseware/course/JumpNavMenuItem.test.jsx +++ b/src/courseware/course/JumpNavMenuItem.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { screen, render } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; import JumpNavMenuItem from './JumpNavMenuItem'; import { fireEvent } from '../../setupTest'; @@ -26,9 +27,11 @@ const mockData = { }; describe('JumpNavMenuItem', () => { render( - , + + + , ); it('renders menu Item as expected with button and Text and handles clicks', () => { expect(screen.queryAllByRole('button')).toHaveLength(1); diff --git a/src/courseware/course/course-exit/CourseExit.jsx b/src/courseware/course/course-exit/CourseExit.jsx index d6bff4084a..7f74204776 100644 --- a/src/courseware/course/course-exit/CourseExit.jsx +++ b/src/courseware/course/course-exit/CourseExit.jsx @@ -4,7 +4,7 @@ import { getConfig } from '@edx/frontend-platform'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button } from '@edx/paragon'; import { useSelector } from 'react-redux'; -import { Redirect } from 'react-router-dom'; +import { Navigate } from 'react-router-dom'; import CourseCelebration from './CourseCelebration'; import CourseInProgress from './CourseInProgress'; @@ -58,7 +58,7 @@ const CourseExit = ({ intl }) => { } else if (mode === COURSE_EXIT_MODES.celebration) { body = (); } else { - return (); + return (); } return ( diff --git a/src/courseware/course/course-exit/CourseExit.test.jsx b/src/courseware/course/course-exit/CourseExit.test.jsx index b079db8b30..d0a212da95 100644 --- a/src/courseware/course/course-exit/CourseExit.test.jsx +++ b/src/courseware/course/course-exit/CourseExit.test.jsx @@ -51,7 +51,7 @@ describe('Course Exit Pages', () => { async function fetchAndRender(component) { await executeThunk(fetchCourse(courseId), store.dispatch); - render(component, { store }); + render(component, { store, wrapWithRouter: true }); } beforeEach(() => { diff --git a/src/courseware/course/sequence/Sequence.test.jsx b/src/courseware/course/sequence/Sequence.test.jsx index 716de86919..dea0649d83 100644 --- a/src/courseware/course/sequence/Sequence.test.jsx +++ b/src/courseware/course/sequence/Sequence.test.jsx @@ -11,6 +11,10 @@ import Sequence from './Sequence'; import { fetchSequenceFailure } from '../../data/slice'; jest.mock('@edx/frontend-platform/analytics'); +jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({ + ...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'), + checkExamEntry: () => jest.fn(), +})); describe('Sequence', () => { let mockData; @@ -42,7 +46,10 @@ describe('Sequence', () => { it('renders correctly without data', async () => { const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false); - render(, { store: testStore }); + render( + , + { store: testStore, wrapWithRouter: true }, + ); expect(screen.getByText('There is no content here.')).toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); @@ -71,7 +78,7 @@ describe('Sequence', () => { }, false); const { container } = render( , - { store: testStore }, + { store: testStore, wrapWithRouter: true }, ); await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument()); @@ -104,7 +111,7 @@ describe('Sequence', () => { }, false); render( , - { store: testStore }, + { store: testStore, wrapWithRouter: true }, ); await waitFor(() => { @@ -121,13 +128,13 @@ describe('Sequence', () => { it('displays error message on sequence load failure', async () => { const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false); testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId })); - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument(); }); it('handles loading unit', async () => { - render(); + render(, { wrapWithRouter: true }); expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument(); // `Previous`, `Bookmark` and `Close Tray` buttons expect(screen.getAllByRole('button')).toHaveLength(3); @@ -167,7 +174,7 @@ describe('Sequence', () => { sequenceId: sequenceBlocks[1].id, previousSequenceHandler: jest.fn(), }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument(); const sequencePreviousButton = screen.getByRole('link', { name: /previous/i }); @@ -203,7 +210,7 @@ describe('Sequence', () => { sequenceId: sequenceBlocks[0].id, nextSequenceHandler: jest.fn(), }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument(); const sequenceNextButton = screen.getByRole('link', { name: /next/i }); @@ -241,7 +248,7 @@ describe('Sequence', () => { previousSequenceHandler: jest.fn(), nextSequenceHandler: jest.fn(), }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument()); fireEvent.click(screen.getByRole('link', { name: /previous/i })); @@ -265,7 +272,7 @@ describe('Sequence', () => { unitNavigationHandler: jest.fn(), previousSequenceHandler: jest.fn(), }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); loadUnit(); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); @@ -284,7 +291,7 @@ describe('Sequence', () => { unitNavigationHandler: jest.fn(), nextSequenceHandler: jest.fn(), }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); loadUnit(); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); @@ -326,7 +333,7 @@ describe('Sequence', () => { nextSequenceHandler: jest.fn(), }; - render(, { store: innerTestStore }); + render(, { store: innerTestStore, wrapWithRouter: true }); loadUnit(); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); @@ -374,7 +381,7 @@ describe('Sequence', () => { sequenceId: sequenceBlocks[0].id, unitNavigationHandler: jest.fn(), }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument()); fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name })); @@ -401,13 +408,13 @@ describe('Sequence', () => { describe('notification feature', () => { it('renders notification tray in sequence', async () => { - render( null }} />); + render( null }} />, { wrapWithRouter: true }); expect(await screen.findByText('Notifications')).toBeInTheDocument(); }); it('handles click on notification tray close button', async () => { const toggleNotificationTray = jest.fn(); - render(); + render(, { wrapWithRouter: true }); const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i }); fireEvent.click(notificationCloseIconButton); expect(toggleNotificationTray).toHaveBeenCalledTimes(1); @@ -415,7 +422,7 @@ describe('Sequence', () => { it('does not render notification tray in sequence by default if in responsive view', async () => { global.innerWidth = breakpoints.medium.maxWidth; - const { container } = render(); + const { container } = render(, { wrapWithRouter: true }); // unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead: expect(container).not.toHaveClass('notification-tray-container'); }); diff --git a/src/courseware/course/sequence/SequenceContent.test.jsx b/src/courseware/course/sequence/SequenceContent.test.jsx index 99a3b078b1..a2f14490d3 100644 --- a/src/courseware/course/sequence/SequenceContent.test.jsx +++ b/src/courseware/course/sequence/SequenceContent.test.jsx @@ -19,13 +19,13 @@ describe('Sequence Content', () => { }); it('displays loading message', () => { - render(); + render(, { wrapWithRouter: true }); expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); }); it('displays messages for the locked content', async () => { const { gatedContent } = store.getState().models.sequences[mockData.sequenceId]; - const { container } = render(); + const { container } = render(, { wrapWithRouter: true }); expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument(); expect(await screen.findByText('Content Locked')).toBeInTheDocument(); @@ -38,7 +38,7 @@ describe('Sequence Content', () => { }); it('displays message for no content', () => { - render(); + render(, { wrapWithRouter: true }); expect(screen.getByText('There is no content here.')).toBeInTheDocument(); }); }); diff --git a/src/courseware/course/sequence/content-lock/ContentLock.jsx b/src/courseware/course/sequence/content-lock/ContentLock.jsx index 26393fec0c..319dcdb70a 100644 --- a/src/courseware/course/sequence/content-lock/ContentLock.jsx +++ b/src/courseware/course/sequence/content-lock/ContentLock.jsx @@ -1,9 +1,9 @@ import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; +import { useNavigate } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faLock } from '@fortawesome/free-solid-svg-icons'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { history } from '@edx/frontend-platform'; import { Button } from '@edx/paragon'; import messages from './messages'; @@ -11,8 +11,9 @@ import messages from './messages'; const ContentLock = ({ intl, courseId, prereqSectionName, prereqId, sequenceTitle, }) => { + const navigate = useNavigate(); const handleClick = useCallback(() => { - history.push(`/course/${courseId}/${prereqId}`); + navigate(`/course/${courseId}/${prereqId}`); }, [courseId, prereqId]); return ( diff --git a/src/courseware/course/sequence/content-lock/ContentLock.test.jsx b/src/courseware/course/sequence/content-lock/ContentLock.test.jsx index 500b507866..c2ab9d3dac 100644 --- a/src/courseware/course/sequence/content-lock/ContentLock.test.jsx +++ b/src/courseware/course/sequence/content-lock/ContentLock.test.jsx @@ -1,10 +1,16 @@ import React from 'react'; -import { history } from '@edx/frontend-platform'; import { render, screen, fireEvent, initializeMockApp, } from '../../../../setupTest'; import ContentLock from './ContentLock'; +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + describe('Content Lock', () => { const mockData = { courseId: 'test-course-id', @@ -19,7 +25,7 @@ describe('Content Lock', () => { }); it('displays sequence title along with lock icon', () => { - const { container } = render(); + const { container } = render(, { wrapWithRouter: true }); const lockIcon = container.querySelector('svg'); expect(lockIcon).toHaveClass('fa-lock'); @@ -28,16 +34,15 @@ describe('Content Lock', () => { it('displays prerequisite name', () => { const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`; - render(); + render(, { wrapWithRouter: true }); expect(screen.getByText(prereqText)).toBeInTheDocument(); }); it('handles click', () => { - history.push = jest.fn(); - render(); + render(, { wrapWithRouter: true }); fireEvent.click(screen.getByRole('button')); - expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`); + expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`); }); }); diff --git a/src/courseware/course/sequence/honor-code/HonorCode.jsx b/src/courseware/course/sequence/honor-code/HonorCode.jsx index 11e7d59468..d1b7d024c0 100644 --- a/src/courseware/course/sequence/honor-code/HonorCode.jsx +++ b/src/courseware/course/sequence/honor-code/HonorCode.jsx @@ -1,16 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; -import { getConfig, history } from '@edx/frontend-platform'; +import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { ActionRow, Alert, Button } from '@edx/paragon'; +import { useNavigate } from 'react-router-dom'; import { useModel } from '../../../../generic/model-store'; import { saveIntegritySignature } from '../../../data'; import messages from './messages'; const HonorCode = ({ intl, courseId }) => { + const navigate = useNavigate(); const dispatch = useDispatch(); const { isMasquerading, @@ -20,7 +22,7 @@ const HonorCode = ({ intl, courseId }) => { const siteName = getConfig().SITE_NAME; const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`; - const handleCancel = () => history.push(`/course/${courseId}/home`); + const handleCancel = () => navigate(`/course/${courseId}/home`); const handleAgree = () => dispatch( // If the request is made by a staff user masquerading as a specific learner, diff --git a/src/courseware/course/sequence/honor-code/HonorCode.test.jsx b/src/courseware/course/sequence/honor-code/HonorCode.test.jsx index c0cf779901..d0c38bde4d 100644 --- a/src/courseware/course/sequence/honor-code/HonorCode.test.jsx +++ b/src/courseware/course/sequence/honor-code/HonorCode.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { getConfig, history } from '@edx/frontend-platform'; +import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import MockAdapter from 'axios-mock-adapter'; import { Factory } from 'rosie'; @@ -9,12 +9,12 @@ import { } from '../../../../setupTest'; import HonorCode from './HonorCode'; +const mockNavigate = jest.fn(); + initializeMockApp(); -jest.mock('@edx/frontend-platform', () => ({ - ...jest.requireActual('@edx/frontend-platform'), - history: { - push: jest.fn(), - }, +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, })); describe('Honor Code', () => { @@ -38,15 +38,15 @@ describe('Honor Code', () => { it('cancel button links to course home ', async () => { await setupStoreState(); - render(); + render(, { wrapWithRouter: true }); const cancelButton = screen.getByText('Cancel'); fireEvent.click(cancelButton); - expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`); + expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`); }); it('calls to save integrity_signature when agreeing', async () => { await setupStoreState({ username: authenticatedUser.username }); - render(); + render(, { wrapWithRouter: true }); const agreeButton = screen.getByText('I agree'); fireEvent.click(agreeButton); await waitFor(() => { @@ -63,7 +63,7 @@ describe('Honor Code', () => { username: authenticatedUser.username, }, ); - render(); + render(, { wrapWithRouter: true }); const agreeButton = screen.getByText('I agree'); fireEvent.click(agreeButton); await waitFor(() => { @@ -80,7 +80,7 @@ describe('Honor Code', () => { username: 'otheruser', }, ); - render(); + render(, { wrapWithRouter: true }); const agreeButton = screen.getByText('I agree'); fireEvent.click(agreeButton); await waitFor(() => { diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx index 1864bde0c0..aa0b157e2d 100644 --- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx +++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx @@ -33,13 +33,13 @@ describe('Sequence Navigation', () => { it('is empty while loading', async () => { const testStore = await initializeTestStore({ excludeFetchSequence: true }, false); - const { container } = render(, { store: testStore }); + const { container } = render(, { store: testStore, wrapWithRouter: true }); expect(container).toBeEmptyDOMElement(); }); it('renders empty div without unitId', () => { - const { container } = render(); + const { container } = render(, { wrapWithRouter: true }); expect(getByText(container, (content, element) => ( element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement(); }); @@ -61,7 +61,7 @@ describe('Sequence Navigation', () => { sequenceId: sequenceBlocks[0].id, onNavigate: jest.fn(), }; - render(, { store: testStore }); + render(, { store: testStore, wrapWithRouter: true }); const unitButton = screen.getByTitle(unitBlocks[1].display_name); fireEvent.click(unitButton); @@ -74,7 +74,7 @@ describe('Sequence Navigation', () => { it('renders correctly and handles unit button clicks', () => { const onNavigate = jest.fn(); - render(); + render(, { wrapWithRouter: true }); const unitButtons = screen.getAllByRole('link', { name: /\d+/ }); expect(unitButtons).toHaveLength(unitButtons.length); @@ -83,7 +83,7 @@ describe('Sequence Navigation', () => { }); it('has both navigation buttons enabled for a non-corner unit of the sequence', () => { - render(); + render(, { wrapWithRouter: true }); screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => { expect(button).toBeEnabled(); @@ -91,7 +91,7 @@ describe('Sequence Navigation', () => { }); it('has the "Previous" button disabled for the first unit of the sequence', () => { - render(); + render(, { wrapWithRouter: true }); expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); expect(screen.getByRole('link', { name: /next/i })).toBeEnabled(); @@ -106,7 +106,7 @@ describe('Sequence Navigation', () => { render( , - { store: testStore }, + { store: testStore, wrapWithRouter: true }, ); expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); @@ -122,7 +122,7 @@ describe('Sequence Navigation', () => { render( , - { store: testStore }, + { store: testStore, wrapWithRouter: true }, ); expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); @@ -143,7 +143,7 @@ describe('Sequence Navigation', () => { render( , - { store: testStore }, + { store: testStore, wrapWithRouter: true }, ); expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); @@ -153,7 +153,7 @@ describe('Sequence Navigation', () => { it('handles "Previous" and "Next" click', () => { const previousHandler = jest.fn(); const nextHandler = jest.fn(); - render(); + render(, { wrapWithRouter: true }); fireEvent.click(screen.getByRole('link', { name: /previous/i })); expect(previousHandler).toHaveBeenCalledTimes(1); diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx index 2da62e5374..acda574105 100644 --- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx +++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx @@ -40,7 +40,10 @@ describe('Sequence Navigation Dropdown', () => { unitBlocks.forEach((unit, index) => { it(`marks unit ${index + 1} as active`, async () => { - const { container } = render(); + const { container } = render( + , + { wrapWithRouter: true }, + ); const dropdownToggle = container.querySelector('.dropdown-toggle'); await act(async () => { await fireEvent.click(dropdownToggle); @@ -59,7 +62,10 @@ describe('Sequence Navigation Dropdown', () => { it('handles the clicks', () => { const onNavigate = jest.fn(); - const { container } = render(); + const { container } = render( + , + { wrapWithRouter: true }, + ); const dropdownToggle = container.querySelector('.dropdown-toggle'); act(() => { diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx index 3fcc7ae758..b35b33c760 100644 --- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx +++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx @@ -41,7 +41,7 @@ describe('Sequence Navigation Tabs', () => { it('renders unit buttons', () => { useIndexOfLastVisibleChild.mockReturnValue([0, null, null]); - render(); + render(, { wrapWithRouter: true }); expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length); }); @@ -50,7 +50,7 @@ describe('Sequence Navigation Tabs', () => { let container = null; await act(async () => { useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]); - const booyah = render(); + const booyah = render(, { wrapWithRouter: true }); container = booyah.container; const dropdownToggle = container.querySelector('.dropdown-toggle'); diff --git a/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx b/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx index a7979625d6..2885a21565 100644 --- a/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx +++ b/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx @@ -32,12 +32,12 @@ describe('Unit Button', () => { }); it('hides title by default', () => { - render(); + render(, { wrapWithRouter: true }); expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name); }); it('shows title', () => { - render(); + render(, { wrapWithRouter: true }); expect(screen.getByRole('link')).toHaveTextContent(unit.display_name); }); @@ -49,7 +49,7 @@ describe('Unit Button', () => { }); it('shows completion for completed unit', () => { - const { container } = render(); + const { container } = render(, { wrapWithRouter: true }); const buttonIcons = container.querySelectorAll('svg'); expect(buttonIcons).toHaveLength(2); expect(buttonIcons[1]).toHaveClass('fa-check'); @@ -70,7 +70,7 @@ describe('Unit Button', () => { }); it('shows bookmark', () => { - const { container } = render(); + const { container } = render(, { wrapWithRouter: true }); const buttonIcons = container.querySelectorAll('svg'); expect(buttonIcons).toHaveLength(3); expect(buttonIcons[2]).toHaveClass('fa-bookmark'); @@ -78,7 +78,7 @@ describe('Unit Button', () => { it('handles the click', () => { const onClick = jest.fn(); - render(); + render(, { wrapWithRouter: true }); fireEvent.click(screen.getByRole('link')); expect(onClick).toHaveBeenCalledTimes(1); }); diff --git a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx index fdeec1669c..89603e6534 100644 --- a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx +++ b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.test.jsx @@ -32,7 +32,7 @@ describe('Unit Navigation', () => { unitId="" onClickPrevious={() => {}} onClickNext={() => {}} - />); + />, { wrapWithRouter: true }); // Only "Previous" and "Next" buttons should be rendered. expect(screen.getAllByRole('link')).toHaveLength(2); @@ -46,7 +46,7 @@ describe('Unit Navigation', () => { {...mockData} onClickPrevious={onClickPrevious} onClickNext={onClickNext} - />); + />, { wrapWithRouter: true }); fireEvent.click(screen.getByRole('link', { name: /previous/i })); expect(onClickPrevious).toHaveBeenCalledTimes(1); @@ -56,7 +56,7 @@ describe('Unit Navigation', () => { }); it('has the navigation buttons enabled for the non-corner unit in the sequence', () => { - render(); + render(, { wrapWithRouter: true }); screen.getAllByRole('link').forEach(button => { expect(button).toBeEnabled(); @@ -64,7 +64,7 @@ describe('Unit Navigation', () => { }); it('has the "Previous" button disabled for the first unit in the sequence', () => { - render(); + render(, { wrapWithRouter: true }); expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); expect(screen.getByRole('link', { name: /next/i })).toBeEnabled(); @@ -79,7 +79,7 @@ describe('Unit Navigation', () => { render( , - { store: testStore }, + { store: testStore, wrapWithRouter: true }, ); expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); @@ -95,7 +95,7 @@ describe('Unit Navigation', () => { render( , - { store: testStore }, + { store: testStore, wrapWithRouter: true }, ); expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); @@ -116,7 +116,7 @@ describe('Unit Navigation', () => { render( , - { store: testStore }, + { store: testStore, wrapWithRouter: true }, ); expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled(); diff --git a/src/courseware/utils.jsx b/src/courseware/utils.jsx new file mode 100644 index 0000000000..2c4c337996 --- /dev/null +++ b/src/courseware/utils.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { useNavigate, useParams } from 'react-router-dom'; + +const withParamsAndNavigation = WrappedComponent => { + const WithParamsNavigationComponent = props => { + const { courseId, sequenceId, unitId } = useParams(); + const navigate = useNavigate(); + return ( + + ); + }; + return WithParamsNavigationComponent; +}; + +export default withParamsAndNavigation; diff --git a/src/decode-page-route/__snapshots__/index.test.jsx.snap b/src/decode-page-route/__snapshots__/index.test.jsx.snap index aff8eac7bd..9a9bd772fa 100644 --- a/src/decode-page-route/__snapshots__/index.test.jsx.snap +++ b/src/decode-page-route/__snapshots__/index.test.jsx.snap @@ -2,15 +2,16 @@ exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
- PageRoute: { - "computedMatch": { - "path": "/course/:courseId/home", - "url": "/course/course-v1:edX+DemoX+Demo_Course/home", - "isExact": true, - "params": { - "courseId": "course-v1:edX+DemoX+Demo_Course" - } - } + PageWrap: { + "children": [ + " ", + [ + " ", + [], + " " + ], + " " + ] }
`; diff --git a/src/decode-page-route/index.jsx b/src/decode-page-route/index.jsx index cc38013b24..eff47fa3fd 100644 --- a/src/decode-page-route/index.jsx +++ b/src/decode-page-route/index.jsx @@ -1,7 +1,15 @@ import PropTypes from 'prop-types'; -import { PageRoute } from '@edx/frontend-platform/react'; +import { PageWrap } from '@edx/frontend-platform/react'; import React from 'react'; -import { useHistory, generatePath } from 'react-router'; +import { + generatePath, useMatch, Navigate, +} from 'react-router-dom'; + +import { DECODE_ROUTES } from '../constants'; + +const ROUTES = [].concat( + ...Object.values(DECODE_ROUTES).map(value => (Array.isArray(value) ? value : [value])), +); export const decodeUrl = (encodedUrl) => { const decodedUrl = decodeURIComponent(encodedUrl); @@ -11,10 +19,16 @@ export const decodeUrl = (encodedUrl) => { return decodeUrl(decodedUrl); }; -const DecodePageRoute = (props) => { - const history = useHistory(); - if (props.computedMatch) { - const { url, path, params } = props.computedMatch; +const DecodePageRoute = ({ children }) => { + let computedMatch = null; + + ROUTES.forEach((route) => { + const matchedRoute = useMatch(route); + if (matchedRoute) { computedMatch = matchedRoute; } + }); + + if (computedMatch) { + const { pathname, pattern, params } = computedMatch; Object.keys(params).forEach((param) => { // only decode params not the entire url. @@ -22,28 +36,19 @@ const DecodePageRoute = (props) => { params[param] = decodeUrl(params[param]); }); - const newUrl = generatePath(path, params); + const newUrl = generatePath(pattern.path, params); // if the url get decoded, reroute to the decoded url - if (newUrl !== url) { - history.replace(newUrl); + if (newUrl !== pathname) { + return ; } } - return ; + return {children} ; }; DecodePageRoute.propTypes = { - computedMatch: PropTypes.shape({ - url: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - // eslint-disable-next-line react/forbid-prop-types - params: PropTypes.any, - }), -}; - -DecodePageRoute.defaultProps = { - computedMatch: null, + children: PropTypes.node.isRequired, }; export default DecodePageRoute; diff --git a/src/decode-page-route/index.test.jsx b/src/decode-page-route/index.test.jsx index c32453edb4..8abf352dbd 100644 --- a/src/decode-page-route/index.test.jsx +++ b/src/decode-page-route/index.test.jsx @@ -1,7 +1,8 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; -import { Router, matchPath } from 'react-router'; +import { + MemoryRouter as Router, matchPath, Routes, Route, mockNavigate, +} from 'react-router-dom'; import DecodePageRoute, { decodeUrl } from '.'; const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course'; @@ -15,84 +16,90 @@ const deepEncodedCourseId = (() => { })(); jest.mock('@edx/frontend-platform/react', () => ({ - PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`, + PageWrap: (props) => `PageWrap: ${JSON.stringify(props, null, 2)}`, +})); +jest.mock('../constants', () => ({ + DECODE_ROUTES: { + MOCK_ROUTE_1: '/course/:courseId/home', + MOCK_ROUTE_2: `/course/:courseId/${encodeURIComponent('some+thing')}/:unitId`, + }, })); -const renderPage = (props) => { - const memHistory = createMemoryHistory({ - initialEntries: [props?.path], - }); +jest.mock('react-router-dom', () => { + const mockNavigation = jest.fn(); + + // eslint-disable-next-line react/prop-types + const Navigate = ({ to }) => { + mockNavigation(to); + return
; + }; - const history = { - ...memHistory, - replace: jest.fn(), + return { + ...jest.requireActual('react-router-dom'), + Navigate, + mockNavigate: mockNavigation, }; +}); +const renderPage = (props) => { const { container } = render( - - + + + {[]} } /> + , ); - return { - container, - history, - props, - }; + return { container }; }; describe('DecodePageRoute', () => { + afterEach(() => { + mockNavigate.mockClear(); + }); + it('should not modify the url if it does not need to be decoded', () => { - const props = matchPath(`/course/${decodedCourseId}/home`, { + const props = matchPath({ path: '/course/:courseId/home', - }); - const { container, history } = renderPage(props); + }, `/course/${decodedCourseId}/home`); + const { container } = renderPage(props); - expect(props.url).toContain(decodedCourseId); - expect(history.replace).not.toHaveBeenCalled(); + expect(props.pathname).toContain(decodedCourseId); + expect(mockNavigate).not.toHaveBeenCalled(); expect(container).toMatchSnapshot(); }); it('should decode the url and replace the history if necessary', () => { - const props = matchPath(`/course/${encodedCourseId}/home`, { + const props = matchPath({ path: '/course/:courseId/home', - }); - const { history } = renderPage(props); + }, `/course/${encodedCourseId}/home`); + renderPage(props); - expect(props.url).not.toContain(decodedCourseId); - expect(props.url).toContain(encodedCourseId); - expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId); + expect(props.pathname).not.toContain(decodedCourseId); + expect(props.pathname).toContain(encodedCourseId); + expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`); }); it('should decode the url multiple times if necessary', () => { - const props = matchPath(`/course/${deepEncodedCourseId}/home`, { + const props = matchPath({ path: '/course/:courseId/home', - }); - const { history } = renderPage(props); + }, `/course/${deepEncodedCourseId}/home`); + renderPage(props); - expect(props.url).not.toContain(decodedCourseId); - expect(props.url).toContain(deepEncodedCourseId); - expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId); + expect(props.pathname).not.toContain(decodedCourseId); + expect(props.pathname).toContain(deepEncodedCourseId); + expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`); }); it('should only decode the url params and not the entire url', () => { const decodedUnitId = 'some+thing'; const encodedUnitId = encodeURIComponent(decodedUnitId); - const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, { + const props = matchPath({ path: `/course/:courseId/${encodedUnitId}/:unitId`, - }); - const { history } = renderPage(props); - - const decodedUrls = history.replace.mock.calls[0][0].split('/'); - - // unitId get decoded - expect(decodedUrls.pop()).toContain(decodedUnitId); - - // path remain encoded - expect(decodedUrls.pop()).toContain(encodedUnitId); + }, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`); + renderPage(props); - // courseId get decoded - expect(decodedUrls.pop()).toContain(decodedCourseId); + expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`); }); }); diff --git a/src/generic/CourseAccessErrorPage.jsx b/src/generic/CourseAccessErrorPage.jsx index 3440382ff4..8eff1f7aec 100644 --- a/src/generic/CourseAccessErrorPage.jsx +++ b/src/generic/CourseAccessErrorPage.jsx @@ -1,9 +1,8 @@ import React, { useEffect } from 'react'; import { LearningHeader as Header } from '@edx/frontend-component-header'; import Footer from '@edx/frontend-component-footer'; -import { useParams } from 'react-router-dom'; +import { useParams, Navigate } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; -import { Redirect } from 'react-router'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import useActiveEnterpriseAlert from '../alerts/active-enteprise-alert'; import { AlertList } from './user-messages'; @@ -38,7 +37,7 @@ const CourseAccessErrorPage = ({ intl }) => { ); } if (courseStatus === LOADED) { - return (); + return ; } return ( <> diff --git a/src/generic/CourseAccessErrorPage.test.jsx b/src/generic/CourseAccessErrorPage.test.jsx index 1361c391ea..340e5d07b9 100644 --- a/src/generic/CourseAccessErrorPage.test.jsx +++ b/src/generic/CourseAccessErrorPage.test.jsx @@ -1,11 +1,13 @@ import React from 'react'; import { history } from '@edx/frontend-platform'; -import { Route } from 'react-router'; +import { Routes, Route } from 'react-router-dom'; import { initializeTestStore, render, screen } from '../setupTest'; import CourseAccessErrorPage from './CourseAccessErrorPage'; const mockDispatch = jest.fn(); +const mockNavigate = jest.fn(); let mockCourseStatus; + jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useDispatch: () => mockDispatch, @@ -14,6 +16,10 @@ jest.mock('react-redux', () => ({ jest.mock('./PageLoading', () => function () { return
; }); +jest.mock('react-router-dom', () => ({ + ...(jest.requireActual('react-router-dom')), + useNavigate: () => mockNavigate, +})); describe('CourseAccessErrorPage', () => { let courseId; @@ -28,33 +34,36 @@ describe('CourseAccessErrorPage', () => { it('Displays loading in start on page rendering', () => { mockCourseStatus = 'loading'; render( - - - , + + } /> + , + { wrapWithRouter: true }, ); expect(screen.getByTestId('page-loading')).toBeInTheDocument(); - expect(history.location.pathname).toBe(accessDeniedUrl); + expect(window.location.pathname).toBe(accessDeniedUrl); }); it('Redirect user to homepage if user has access', () => { mockCourseStatus = 'loaded'; render( - - - , + + } /> + , + { wrapWithRouter: true }, ); - expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course'); + expect(window.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course'); }); it('For access denied it should render access denied page', () => { mockCourseStatus = 'denied'; render( - - - , + + } /> + , + { wrapWithRouter: true }, ); expect(screen.getByTestId('access-denied-main')).toBeInTheDocument(); - expect(history.location.pathname).toBe(accessDeniedUrl); + expect(window.location.pathname).toBe(accessDeniedUrl); }); }); diff --git a/src/generic/path-fixes/PathFixesProvider.jsx b/src/generic/path-fixes/PathFixesProvider.jsx index 3215660927..83e9552c99 100644 --- a/src/generic/path-fixes/PathFixesProvider.jsx +++ b/src/generic/path-fixes/PathFixesProvider.jsx @@ -1,4 +1,4 @@ -import { Redirect, useLocation } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import PropTypes from 'prop-types'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -16,10 +16,10 @@ const PathFixesProvider = ({ children }) => { // We only check for spaces. That's not the only kind of character that is escaped in URLs, but it would always be // present for our cases, and I believe it's the only one we use normally. - if (location.pathname.includes(' ')) { + if (location.pathname.includes(' ') || location.pathname.includes('%20')) { const newLocation = { ...location, - pathname: location.pathname.replaceAll(' ', '+'), + pathname: (location.pathname.replaceAll(' ', '+')).replaceAll('%20', '+'), }; sendTrackEvent('edx.ui.lms.path_fixed', { @@ -29,7 +29,7 @@ const PathFixesProvider = ({ children }) => { search: location.search, }); - return (); + return (); } return children; // pass through diff --git a/src/generic/path-fixes/PathFixesProvider.test.jsx b/src/generic/path-fixes/PathFixesProvider.test.jsx index c20bbd9949..0253acf9e1 100644 --- a/src/generic/path-fixes/PathFixesProvider.test.jsx +++ b/src/generic/path-fixes/PathFixesProvider.test.jsx @@ -1,5 +1,7 @@ import React from 'react'; -import { MemoryRouter, Route } from 'react-router-dom'; +import { + MemoryRouter, Route, Routes, useLocation, +} from 'react-router-dom'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -19,16 +21,20 @@ describe('PathFixesProvider', () => { }); function buildAndRender(path) { + const LocationComponent = () => { + testLocation = useLocation(); + return null; + }; + render( - { - testLocation = routeProps.location; - return null; - }} - /> + + } + /> + , ); diff --git a/src/index.jsx b/src/index.jsx index 05a72f5f61..d99be587a7 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -6,10 +6,10 @@ import { mergeConfig, getConfig, } from '@edx/frontend-platform'; -import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react'; +import { AppProvider, ErrorPage, PageWrap } from '@edx/frontend-platform/react'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Switch } from 'react-router-dom'; +import { Routes, Route } from 'react-router-dom'; import { Helmet } from 'react-helmet'; import { fetchDiscussionTab, fetchLiveTab } from './course-home/data/thunks'; @@ -36,6 +36,7 @@ import PathFixesProvider from './generic/path-fixes'; import LiveTab from './course-home/live-tab/LiveTab'; import CourseAccessErrorPage from './generic/CourseAccessErrorPage'; import DecodePageRoute from './decode-page-route'; +import { DECODE_ROUTES, ROUTES } from './constants'; subscribe(APP_READY, () => { ReactDOM.render( @@ -46,59 +47,91 @@ subscribe(APP_READY, () => { - - - - - - - - - - - - - - - - - - - - - - - - - ( - fetchProgressTab(courseId, match.params.targetUserId)} - slice="courseHome" - > - - + + } /> + } /> + } + /> + + + + + + )} + /> + + + + + + )} + /> + + + + + )} /> - - - - - - + + + + + )} + /> + {DECODE_ROUTES.PROGRESS.map((route) => ( + + + + + + )} + /> + ))} + + + + + + )} /> - + {DECODE_ROUTES.COURSEWARE.map((route) => ( + + + + )} + /> + ))} + diff --git a/src/product-tours/ProductTours.test.jsx b/src/product-tours/ProductTours.test.jsx index 4f87f1931d..ae4afdcafc 100644 --- a/src/product-tours/ProductTours.test.jsx +++ b/src/product-tours/ProductTours.test.jsx @@ -3,7 +3,7 @@ * @jest-environment jsdom */ import React from 'react'; -import { Route, Switch } from 'react-router'; +import { Route, Routes } from 'react-router-dom'; import { Factory } from 'rosie'; import { getConfig, history } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -26,6 +26,7 @@ import { buildSimpleCourseBlocks } from '../shared/data/__factories__/courseBloc import { buildOutlineFromBlocks } from '../courseware/data/__factories__/learningSequencesOutline.factory'; import { UserMessagesProvider } from '../generic/user-messages'; +import { DECODE_ROUTES } from '../constants'; initializeMockApp(); jest.mock('@edx/frontend-platform/analytics'); @@ -62,7 +63,7 @@ describe('Course Home Tours', () => { , - { store }, + { store, wrapWithRouter: true }, ); } @@ -213,16 +214,14 @@ describe('Courseware Tour', () => { component = ( - - - + + {DECODE_ROUTES.COURSEWARE.map((route) => ( + } + /> + ))} + ); diff --git a/src/setupTest.js b/src/setupTest.js index 225b56b913..371664a781 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -169,13 +169,14 @@ function render( ui, { store = null, + wrapWithRouter = false, ...renderOptions } = {}, ) { const Wrapper = ({ children }) => ( // eslint-disable-next-line react/jsx-filename-extension - + {children} diff --git a/src/tab-page/TabContainer.jsx b/src/tab-page/TabContainer.jsx index c5331f9fe8..d69e62f40f 100644 --- a/src/tab-page/TabContainer.jsx +++ b/src/tab-page/TabContainer.jsx @@ -12,15 +12,21 @@ const TabContainer = (props) => { fetch, slice, tab, + isProgressTab, } = props; - const { courseId: courseIdFromUrl } = useParams(); + const { courseId: courseIdFromUrl, targetUserId } = useParams(); const dispatch = useDispatch(); + useEffect(() => { // The courseId from the URL is the course we WANT to load. - dispatch(fetch(courseIdFromUrl)); + if (isProgressTab) { + dispatch(fetch(courseIdFromUrl, targetUserId)); + } else { + dispatch(fetch(courseIdFromUrl)); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [courseIdFromUrl]); + }, [courseIdFromUrl, targetUserId]); // The courseId from the store is the course we HAVE loaded. If the URL changes, // we don't want the application to adjust to it until it has actually loaded the new data. @@ -47,6 +53,11 @@ TabContainer.propTypes = { fetch: PropTypes.func.isRequired, slice: PropTypes.string.isRequired, tab: PropTypes.string.isRequired, + isProgressTab: PropTypes.bool, +}; + +TabContainer.defaultProps = { + isProgressTab: false, }; export default TabContainer; diff --git a/src/tab-page/TabContainer.test.jsx b/src/tab-page/TabContainer.test.jsx index 6052412947..fc0d5f39e4 100644 --- a/src/tab-page/TabContainer.test.jsx +++ b/src/tab-page/TabContainer.test.jsx @@ -1,6 +1,5 @@ import React from 'react'; -import { history } from '@edx/frontend-platform'; -import { Route } from 'react-router'; +import { Route, Routes, MemoryRouter } from 'react-router-dom'; import { initializeTestStore, render, screen } from '../setupTest'; import { TabContainer } from './index'; @@ -31,13 +30,19 @@ describe('Tab Container', () => { }); it('renders correctly', () => { - history.push(`/course/${courseId}`); render( - - - children={[]} - - , + + + + children={[]} + + )} + /> + + , ); expect(mockFetch).toHaveBeenCalledTimes(1); @@ -49,22 +54,25 @@ describe('Tab Container', () => { it('Should handle passing in a targetUserId', () => { const targetUserId = '1'; - history.push(`/course/${courseId}/progress/${targetUserId}/`); render( - ( - mockFetch(match.params.courseId, match.params.targetUserId)} - tab="dummy" - slice="courseHome" - > - children={[]} - - - )} - />, + + + + children={[]} + + )} + /> + + , ); expect(mockFetch).toHaveBeenCalledTimes(1); diff --git a/src/tab-page/TabPage.jsx b/src/tab-page/TabPage.jsx index 3b9dea60c6..ed4bb455cb 100644 --- a/src/tab-page/TabPage.jsx +++ b/src/tab-page/TabPage.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useDispatch, useSelector } from 'react-redux'; -import { Redirect } from 'react-router'; +import { Navigate } from 'react-router-dom'; import Footer from '@edx/frontend-component-footer'; import { Toast } from '@edx/paragon'; @@ -41,7 +41,7 @@ const TabPage = ({ intl, ...props }) => { if (courseStatus === 'denied') { const redirectUrl = getAccessDeniedRedirectUrl(courseId, activeTabSlug, courseAccess, start); if (redirectUrl) { - return (); + return (); } }