diff --git a/Makefile b/Makefile index 2fc1ab8b3..d29eb79fd 100755 --- a/Makefile +++ b/Makefile @@ -12,6 +12,11 @@ transifex_temp = ./temp/babel-plugin-formatjs NPM_TESTS=build i18n_extract lint test +# Variables for additional translation sources and imports (define in edx-internal if needed) +ATLAS_EXTRA_SOURCES ?= +ATLAS_EXTRA_INTL_IMPORTS ?= +ATLAS_OPTIONS ?= + .PHONY: test test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite @@ -29,13 +34,13 @@ clean: build: clean tsc --project tsconfig.build.json - tsc-alias -p tsconfig.build.json - find src -type f \( -name '*.scss' -o -name '*.png' -o -name '*.svg' \) -exec sh -c '\ + find src -type f \( -name '*.scss' -o \( \( -name '*.png' -o -name '*.svg' \) -path '*/assets/*' \) \) -exec sh -c '\ for f in "$$@"; do \ d="dist/$${f#src/}"; \ mkdir -p "$$(dirname "$$d")"; \ cp "$$f" "$$d"; \ done' sh {} + + tsc-alias -p tsconfig.build.json i18n.extract: # Pulling display strings from .jsx files into .json files... @@ -60,9 +65,10 @@ pull_translations: && atlas pull $(ATLAS_OPTIONS) \ translations/frontend-base/src/i18n/messages:frontend-base \ translations/paragon/src/i18n/messages:paragon \ - translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard + translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard \ + $(ATLAS_EXTRA_SOURCES) - $(intl_imports) frontend-base paragon frontend-app-learner-dashboard + $(intl_imports) frontend-base paragon frontend-app-learner-dashboard $(ATLAS_EXTRA_INTL_IMPORTS) # This target is used by CI. validate-no-uncommitted-package-lock-changes: diff --git a/jest.config.js b/jest.config.js index f55dab329..0f268cabf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,22 +1,21 @@ const { createConfig } = require('@openedx/frontend-base/tools'); -module.exports = createConfig('test', { +const config = createConfig('test', { setupFilesAfterEnv: [ 'jest-expect-message', '/src/setupTest.jsx', ], coveragePathIgnorePatterns: [ 'src/segment.js', - 'src/postcss.config.js', 'testUtils', // don't unit test jest mocking tools - 'src/data/services/lms/fakeData', // don't unit test mock data - 'src/test', // don't unit test integration test utils 'src/__mocks__', ], moduleNameMapper: { - // Asset mocks '\\.svg$': '/src/__mocks__/svg.js', - '\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/src/__mocks__/file.js', + '\\.png$': '/src/__mocks__/file.js', + '^@src/(.*)$': '/src/$1', }, testTimeout: 120000, }); + +module.exports = config; diff --git a/package-lock.json b/package-lock.json index dd98c6085..3981d95e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,46 +15,37 @@ "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/react-fontawesome": "^0.2.0", - "@redux-devtools/extension": "3.3.0", - "@reduxjs/toolkit": "^2.0.0", "classnames": "^2.3.1", - "filesize": "^10.0.0", "font-awesome": "4.7.0", "lodash": "^4.17.21", "moment": "^2.29.4", "prop-types": "15.8.1", - "react-share": "^4.4.0", - "redux-logger": "3.0.6", - "redux-thunk": "2.4.2", - "reselect": "^4.0.0" + "react-share": "^5.2.2" }, "devDependencies": { "@edx/browserslist-config": "^1.5.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/jest": "^29.5.14", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-expect-message": "^1.1.3", "jest-when": "^3.6.0", "react-dev-utils": "^12.0.0", - "react-test-renderer": "^18.3.1", - "redux-mock-store": "^1.5.4", "tsc-alias": "^1.8.16" }, "peerDependencies": { - "@openedx/frontend-base": "^1.0.0-alpha.13", + "@openedx/frontend-base": "^1.0.0-alpha.14", "@openedx/paragon": "^23", "@tanstack/react-query": "^5", "@types/react": "^18", "@types/react-dom": "^18", "react": "^18", "react-dom": "^18", - "react-redux": "^8", "react-router": "^6", - "react-router-dom": "^6", - "redux": "^4" + "react-router-dom": "^6" } }, "node_modules/@adobe/css-tools": { @@ -1879,6 +1870,21 @@ "url": "^0.11.3" } }, + "node_modules/@bundled-es-modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@bundled-es-modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/@bundled-es-modules/glob/node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -1924,6 +1930,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@bundled-es-modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@bundled-es-modules/memfs": { "version": "4.17.0", "resolved": "https://registry.npmjs.org/@bundled-es-modules/memfs/-/memfs-4.17.0.tgz", @@ -1964,19 +1985,19 @@ } }, "node_modules/@bundled-es-modules/memfs/node_modules/memfs": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.56.10.tgz", - "integrity": "sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.56.11.tgz", + "integrity": "sha512-/GodtwVeKVIHZKLUSr2ZdOxKBC5hHki4JNCU22DoCGPEHr5o2PD5U721zvESKyWwCfTfavFl9WZYgA13OAYK0g==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-fsa": "4.56.10", - "@jsonjoy.com/fs-node": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-to-fsa": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/fs-print": "4.56.10", - "@jsonjoy.com/fs-snapshot": "4.56.10", + "@jsonjoy.com/fs-core": "4.56.11", + "@jsonjoy.com/fs-fsa": "4.56.11", + "@jsonjoy.com/fs-node": "4.56.11", + "@jsonjoy.com/fs-node-builtins": "4.56.11", + "@jsonjoy.com/fs-node-to-fsa": "4.56.11", + "@jsonjoy.com/fs-node-utils": "4.56.11", + "@jsonjoy.com/fs-print": "4.56.11", + "@jsonjoy.com/fs-snapshot": "4.56.11", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", @@ -2002,42 +2023,42 @@ } }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.1.tgz", - "integrity": "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.2.0.tgz", + "integrity": "sha512-ssJFvn/UXhQQeICw3SR/fZPmYVj+JM2mP+Lx7bZ51cOeHaMWOKp3AUMuyM3QR82aFFXTfcAp67P5GpPjGmbZWQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.1.1", - "@chevrotain/types": "11.1.1", + "@chevrotain/gast": "11.2.0", + "@chevrotain/types": "11.2.0", "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/gast": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.1.tgz", - "integrity": "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.2.0.tgz", + "integrity": "sha512-c+KoD6eSI1xjAZZoNUW+V0l13UEn+a4ShmUrjIKs1BeEWCji0Kwhmqn5FSx1K4BhWL7IQKlV7wLR4r8lLArORQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.1.1", + "@chevrotain/types": "11.2.0", "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.1.tgz", - "integrity": "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.2.0.tgz", + "integrity": "sha512-lG73pBFqbXODTbXhdZwv0oyUaI+3Irm+uOv5/W79lI3g5hasYaJnVJOm3H2NkhA0Ef4XLBU4Scr7TJDJwgFkAw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.1.tgz", - "integrity": "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.2.0.tgz", + "integrity": "sha512-vBMSj/lz/LqolbGQEHB0tlpW5BnljHVtp+kzjQfQU+5BtGMTuZCPVgaAjtKvQYXnHb/8i/02Kii00y0tsuwfsw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.1.tgz", - "integrity": "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.2.0.tgz", + "integrity": "sha512-+7whECg4yNWHottjvr2To2BRxL4XJVjIyyv5J4+bJ0iMOVU8j/8n1qPDLZS/90W/BObDR8VNL46lFbzY/Hosmw==", "license": "Apache-2.0" }, "node_modules/@csstools/cascade-layer-name-parser": { @@ -2253,6 +2274,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2264,9 +2291,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -2300,19 +2327,19 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" }, "engines": { @@ -2328,6 +2355,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2363,9 +2396,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -2375,9 +2408,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2409,15 +2442,15 @@ } }, "node_modules/@formatjs/cli": { - "version": "6.12.2", - "resolved": "https://registry.npmjs.org/@formatjs/cli/-/cli-6.12.2.tgz", - "integrity": "sha512-y215aarLZXei3u1WDRAiet/VajgvUXzvz4ifPENDSPXfIog0aBJKtFvIU7EWbcFWy7rstbn5GcwwQW4F1G3TJg==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@formatjs/cli/-/cli-6.13.0.tgz", + "integrity": "sha512-bl4+FNg7S6RPNa9cSAE8HqdXu84n7LpzDdkDAPqS0sk58XNbY/1Le6GdWqCKzELWX+FhI58gyZtZecmWsZ+Bhg==", "license": "MIT", "bin": { "formatjs": "bin/formatjs" }, "engines": { - "node": ">= 16" + "node": ">= 20.12.0" }, "peerDependencies": { "@glimmer/syntax": "^0.84.3 || ^0.95.0", @@ -3244,12 +3277,12 @@ } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -3742,13 +3775,13 @@ } }, "node_modules/@jsonjoy.com/fs-core": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.56.10.tgz", - "integrity": "sha512-PyAEA/3cnHhsGcdY+AmIU+ZPqTuZkDhCXQ2wkXypdLitSpd6d5Ivxhnq4wa2ETRWFVJGabYynBWxIijOswSmOw==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.56.11.tgz", + "integrity": "sha512-wThHjzUp01ImIjfCwhs+UnFkeGPFAymwLEkOtenHewaKe2pTP12p6r1UuwikA9NEvNf9Vlck92r8fb8n/MWM5w==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", + "@jsonjoy.com/fs-node-builtins": "4.56.11", + "@jsonjoy.com/fs-node-utils": "4.56.11", "thingies": "^2.5.0" }, "engines": { @@ -3763,14 +3796,14 @@ } }, "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.56.10.tgz", - "integrity": "sha512-/FVK63ysNzTPOnCCcPoPHt77TOmachdMS422txM4KhxddLdbW1fIbFMYH0AM0ow/YchCyS5gqEjKLNyv71j/5Q==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.56.11.tgz", + "integrity": "sha512-ZYlF3XbMayyp97xEN8ZvYutU99PCHjM64mMZvnCseXkCJXJDVLAwlF8Q/7q/xiWQRsv3pQBj1WXHd9eEyYcaCQ==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", + "@jsonjoy.com/fs-core": "4.56.11", + "@jsonjoy.com/fs-node-builtins": "4.56.11", + "@jsonjoy.com/fs-node-utils": "4.56.11", "thingies": "^2.5.0" }, "engines": { @@ -3785,16 +3818,16 @@ } }, "node_modules/@jsonjoy.com/fs-node": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.56.10.tgz", - "integrity": "sha512-7R4Gv3tkUdW3dXfXiOkqxkElxKNVdd8BDOWC0/dbERd0pXpPY+s2s1Mino+aTvkGrFPiY+mmVxA7zhskm4Ue4Q==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.56.11.tgz", + "integrity": "sha512-D65YrnP6wRuZyEWoSFnBJSr5zARVpVBGctnhie4rCsMuGXNzX7IHKaOt85/Aj7SSoG1N2+/xlNjWmkLvZ2H3Tg==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/fs-print": "4.56.10", - "@jsonjoy.com/fs-snapshot": "4.56.10", + "@jsonjoy.com/fs-core": "4.56.11", + "@jsonjoy.com/fs-node-builtins": "4.56.11", + "@jsonjoy.com/fs-node-utils": "4.56.11", + "@jsonjoy.com/fs-print": "4.56.11", + "@jsonjoy.com/fs-snapshot": "4.56.11", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, @@ -3810,9 +3843,9 @@ } }, "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.56.10.tgz", - "integrity": "sha512-uUnKz8R0YJyKq5jXpZtkGV9U0pJDt8hmYcLRrPjROheIfjMXsz82kXMgAA/qNg0wrZ1Kv+hrg7azqEZx6XZCVw==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.56.11.tgz", + "integrity": "sha512-CNmt3a0zMCIhniFLXtzPWuUxXFU+U+2VyQiIrgt/rRVeEJNrMQUABaRbVxR0Ouw1LyR9RjaEkPM6nYpED+y43A==", "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -3826,14 +3859,14 @@ } }, "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.56.10.tgz", - "integrity": "sha512-oH+O6Y4lhn9NyG6aEoFwIBNKZeYy66toP5LJcDOMBgL99BKQMUf/zWJspdRhMdn/3hbzQsZ8EHHsuekbFLGUWw==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.56.11.tgz", + "integrity": "sha512-5OzGdvJDgZVo+xXWEYo72u81zpOWlxlbG4d4nL+hSiW+LKlua/dldNgPrpWxtvhgyntmdFQad2UTxFyGjJAGhA==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10" + "@jsonjoy.com/fs-fsa": "4.56.11", + "@jsonjoy.com/fs-node-builtins": "4.56.11", + "@jsonjoy.com/fs-node-utils": "4.56.11" }, "engines": { "node": ">=10.0" @@ -3847,12 +3880,12 @@ } }, "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.56.10.tgz", - "integrity": "sha512-8EuPBgVI2aDPwFdaNQeNpHsyqPi3rr+85tMNG/lHvQLiVjzoZsvxA//Xd8aB567LUhy4QS03ptT+unkD/DIsNg==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.56.11.tgz", + "integrity": "sha512-JADOZFDA3wRfsuxkT0+MYc4F9hJO2PYDaY66kRTG6NqGX3+bqmKu66YFYAbII/tEmQWPZeHoClUB23rtQM9UPg==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.56.10" + "@jsonjoy.com/fs-node-builtins": "4.56.11" }, "engines": { "node": ">=10.0" @@ -3866,12 +3899,12 @@ } }, "node_modules/@jsonjoy.com/fs-print": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.56.10.tgz", - "integrity": "sha512-JW4fp5mAYepzFsSGrQ48ep8FXxpg4niFWHdF78wDrFGof7F3tKDJln72QFDEn/27M1yHd4v7sKHHVPh78aWcEw==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.56.11.tgz", + "integrity": "sha512-rnaKRgCRIn8JGTjxhS0JPE38YM3Pj/H7SW4/tglhIPbfKEkky7dpPayNKV2qy25SZSL15oFVgH/62dMZ/z7cyA==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.56.10", + "@jsonjoy.com/fs-node-utils": "4.56.11", "tree-dump": "^1.1.0" }, "engines": { @@ -3886,13 +3919,13 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.56.10.tgz", - "integrity": "sha512-DkR6l5fj7+qj0+fVKm/OOXMGfDFCGXLfyHkORH3DF8hxkpDgIHbhf/DwncBMs2igu/ST7OEkexn1gIqoU6Y+9g==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.56.11.tgz", + "integrity": "sha512-IIldPX+cIRQuUol9fQzSS3hqyECxVpYMJQMqdU3dCKZFRzEl1rkIkw4P6y7Oh493sI7YdxZlKr/yWdzEWZ1wGQ==", "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.56.10", + "@jsonjoy.com/fs-node-utils": "4.56.11", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -4171,9 +4204,9 @@ } }, "node_modules/@openedx/frontend-base": { - "version": "1.0.0-alpha.13", - "resolved": "https://registry.npmjs.org/@openedx/frontend-base/-/frontend-base-1.0.0-alpha.13.tgz", - "integrity": "sha512-9y22XZhsfTVwiUnGvPsNQrvd/2bceduX1qUruy5C6eABmcwfNRGu+nx4KGH/raStIiPg0fXPVCnDQWWyj47Qfw==", + "version": "1.0.0-alpha.14", + "resolved": "https://registry.npmjs.org/@openedx/frontend-base/-/frontend-base-1.0.0-alpha.14.tgz", + "integrity": "sha512-VeHxEMhMgRbsfdRklbsynivhcrT16aIZEZNwecOJ3SXP3WTFkZi5GGJG5YDydQ/K/znd3ckT5TgSXipkIrtPRA==", "license": "AGPL-3.0", "peer": true, "dependencies": { @@ -4346,9 +4379,9 @@ } }, "node_modules/@openedx/paragon/node_modules/axios": { - "version": "0.30.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.30.2.tgz", - "integrity": "sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==", + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.30.3.tgz", + "integrity": "sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.4", @@ -4356,6 +4389,21 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/@openedx/paragon/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@openedx/paragon/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/@openedx/paragon/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -4377,9 +4425,9 @@ } }, "node_modules/@openedx/paragon/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -4735,92 +4783,92 @@ } }, "node_modules/@peculiar/asn1-cms": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz", - "integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "@peculiar/asn1-x509-attr": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz", - "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", - "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz", - "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-pkcs8": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz", - "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz", - "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-pfx": "^2.6.0", - "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "@peculiar/asn1-x509-attr": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", - "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } @@ -4837,9 +4885,9 @@ } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", - "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -4849,13 +4897,13 @@ } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz", - "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } @@ -4957,67 +5005,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@redux-devtools/extension": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@redux-devtools/extension/-/extension-3.3.0.tgz", - "integrity": "sha512-X34S/rC8S/M1BIrkYD1mJ5f8vlH0BDqxXrs96cvxSBo4FhMdbhU+GUGsmNYov1xjSyLMHgo8NYrUG8bNX7525g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2", - "immutable": "^4.3.4" - }, - "peerDependencies": { - "redux": "^3.1.0 || ^4.0.0 || ^5.0.0" - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true - }, - "node_modules/@reduxjs/toolkit/node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/@reduxjs/toolkit/node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -5072,18 +5059,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@stylistic/eslint-plugin": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.13.0.tgz", @@ -5255,15 +5230,6 @@ "node": ">= 10" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -5509,6 +5475,52 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", @@ -5527,9 +5539,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "license": "MIT" }, "node_modules/@types/lodash.keyby": { @@ -5554,9 +5566,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5694,12 +5706,6 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", - "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", - "license": "MIT" - }, "node_modules/@types/warning": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", @@ -5731,16 +5737,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", - "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/type-utils": "8.55.0", - "@typescript-eslint/utils": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -5753,8 +5759,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.55.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -5768,16 +5774,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", - "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "engines": { @@ -5788,18 +5794,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", - "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.55.0", - "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "engines": { @@ -5814,13 +5820,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", - "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5831,9 +5837,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", - "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5847,14 +5853,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", - "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -5866,14 +5872,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", - "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5884,17 +5890,17 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", - "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.55.0", - "@typescript-eslint/tsconfig-utils": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/visitor-keys": "8.55.0", + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" @@ -5923,15 +5929,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", - "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.55.0", - "@typescript-eslint/types": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5941,18 +5947,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", - "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.55.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5962,6 +5968,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -6171,9 +6189,9 @@ "license": "BSD-2-Clause" }, "node_modules/@zip.js/zip.js": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.19.tgz", - "integrity": "sha512-GqYlOH4FsIM+vDaYPlQGgLlsyOuO3MPwof/VE/ZFarL4TrbvajGlX/RORFHKlZZUzvv6UiCU73WdfKBvSpEtGA==", + "version": "2.8.23", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.23.tgz", + "integrity": "sha512-RB+RLnxPJFPrGvQ9rgO+4JOcsob6lD32OcF0QE0yg24oeW9q8KnTTNlugcDaIveEcCbclobJcZP+fLQ++sH0bw==", "license": "BSD-3-Clause", "engines": { "bun": ">=0.7.0", @@ -6211,9 +6229,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "peer": true, "bin": { @@ -6255,9 +6273,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -6301,9 +6319,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "peer": true, "dependencies": { @@ -6335,9 +6353,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6743,9 +6761,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "funding": [ { "type": "opencollective", @@ -6763,7 +6781,7 @@ "license": "MIT", "dependencies": { "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", + "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -6803,9 +6821,9 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "peer": true, "dependencies": { @@ -6815,9 +6833,9 @@ } }, "node_modules/axios-cache-interceptor": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.11.4.tgz", - "integrity": "sha512-xZ4OZUxdpcFUpZjrqfYlGK0VglpPRKKSoE3vMHrstxolixQNs/MrbMezOAO5uS454hIEcWpnk75RZK26WkPW/g==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.12.0.tgz", + "integrity": "sha512-15XuJkdeJmQo/HY2b0xx3zim8DMx7Nu+G8R4z6OG2VZLtbIDnsfn4qZsLLvkPfK4SVNRzXnoG4jPR7dqdQznRA==", "license": "MIT", "dependencies": { "cache-parser": "^1.2.6", @@ -6998,10 +7016,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -7024,12 +7045,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/batch": { @@ -7147,12 +7171,15 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -7367,9 +7394,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001776", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", "funding": [ { "type": "opencollective", @@ -7439,16 +7466,16 @@ "license": "MIT" }, "node_modules/chevrotain": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz", - "integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.2.0.tgz", + "integrity": "sha512-mHCHTxM51nCklUw9RzRVc0DLjAh/SAUPM4k/zMInlTIo25ldWXOZoPt7XEIk/LwoT4lFVmJcu9g5MHtx371x3A==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.1.1", - "@chevrotain/gast": "11.1.1", - "@chevrotain/regexp-to-ast": "11.1.1", - "@chevrotain/types": "11.1.1", - "@chevrotain/utils": "11.1.1", + "@chevrotain/cst-dts-gen": "11.2.0", + "@chevrotain/gast": "11.2.0", + "@chevrotain/regexp-to-ast": "11.2.0", + "@chevrotain/types": "11.2.0", + "@chevrotain/utils": "11.2.0", "lodash-es": "4.17.23" } }, @@ -7985,9 +8012,9 @@ } }, "node_modules/css-loader": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.3.tgz", - "integrity": "sha512-frbERmjT0UC5lMheWpJmMilnt9GEhbZJN/heUb7/zaJYeIzj5St9HvDcfshzzOqbsS+rYpMk++2SD3vGETDSyA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", @@ -8007,7 +8034,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "peerDependenciesMeta": { @@ -8346,9 +8373,9 @@ } }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" @@ -8359,13 +8386,6 @@ } } }, - "node_modules/deep-diff": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", - "integrity": "sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8782,9 +8802,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "license": "ISC" }, "node_modules/email-prop-type": { @@ -8850,9 +8870,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -9138,9 +9158,9 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "license": "MIT", "peer": true, "dependencies": { @@ -9150,7 +9170,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -9280,6 +9300,12 @@ "node": ">= 0.4" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -9291,9 +9317,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9346,6 +9372,12 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -9357,9 +9389,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9369,18 +9401,24 @@ } }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9413,6 +9451,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -9455,9 +9499,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9909,12 +9953,12 @@ } }, "node_modules/filesize": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", - "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", "license": "BSD-3-Clause", "engines": { - "node": ">= 10.4.0" + "node": ">= 0.4.0" } }, "node_modules/fill-range": { @@ -10016,9 +10060,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", "license": "ISC" }, "node_modules/focus-lock": { @@ -10132,6 +10176,12 @@ "webpack": "^5.11.0" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -10143,9 +10193,9 @@ } }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -10511,6 +10561,12 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -10522,9 +10578,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11222,9 +11278,9 @@ "license": "MIT" }, "node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", "license": "MIT", "funding": { "type": "opencollective", @@ -11232,9 +11288,9 @@ } }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "license": "MIT" }, "node_modules/import-fresh": { @@ -11790,9 +11846,9 @@ } }, "node_modules/is-network-error": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", - "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", + "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", "license": "MIT", "engines": { "node": ">=16" @@ -13200,13 +13256,13 @@ "license": "MIT" }, "node_modules/js-toml": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/js-toml/-/js-toml-1.0.2.tgz", - "integrity": "sha512-/7IQ//bzn2a/5IDazPUNzlW7bsjxS51cxciYZDR+Z+3Le60yzT0YfI8KOWqTtBcZkXXVklhWd2OuGd8ZksB0wQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/js-toml/-/js-toml-1.0.3.tgz", + "integrity": "sha512-sgyRKshBUSPIlUrbVXYQHReVZUXKHTldaW+Fj7KSan21vgnmMpuAAo00rBvm7W4HQrvZSvv186wNHlIjMPYC/A==", "license": "MIT", "dependencies": { - "chevrotain": "^11.0.3", - "xregexp": "^5.1.1" + "chevrotain": "^11.1.1", + "xregexp": "^5.1.2" } }, "node_modules/js-yaml": { @@ -13454,9 +13510,9 @@ } }, "node_modules/launch-editor": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", - "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.1.tgz", + "integrity": "sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==", "license": "MIT", "dependencies": { "picocolors": "^1.1.1", @@ -13592,13 +13648,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.keyby": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.keyby/-/lodash.keyby-4.6.0.tgz", @@ -13978,15 +14027,15 @@ "license": "ISC" }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -14002,10 +14051,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -14129,6 +14178,24 @@ "license": "MIT", "optional": true }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -14136,9 +14203,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, "node_modules/normalize-path": { @@ -14976,9 +15043,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -15902,9 +15969,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -16111,6 +16178,12 @@ "node": ">=8" } }, + "node_modules/react-dev-utils/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/react-dev-utils/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -16161,15 +16234,6 @@ "node": ">=8" } }, - "node_modules/react-dev-utils/node_modules/filesize": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", - "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/react-dev-utils/node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -16272,16 +16336,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-dev-utils/node_modules/immer": { - "version": "9.0.21", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", - "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/react-dev-utils/node_modules/loader-utils": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", @@ -16307,9 +16361,9 @@ } }, "node_modules/react-dev-utils/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -16665,51 +16719,6 @@ "integrity": "sha512-nopsRn7KnGgazBe2c3H2+Kf+Csp6PGDRLiBkYEDMKY8o/EIgft/WnIm/OnAKTawZiLnJXHAqhpFBddvs6NiXlw==", "license": "MIT" }, - "node_modules/react-redux": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", - "integrity": "sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.1", - "@types/hoist-non-react-statics": "^3.3.1", - "@types/use-sync-external-store": "^0.0.3", - "hoist-non-react-statics": "^3.3.2", - "react-is": "^18.0.0", - "use-sync-external-store": "^1.0.0" - }, - "peerDependencies": { - "@types/react": "^16.8 || ^17.0 || ^18.0", - "@types/react-dom": "^16.8 || ^17.0 || ^18.0", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0", - "react-native": ">=0.59", - "redux": "^4 || ^5.0.0-beta.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/react-redux/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/react-refresh": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.16.0.tgz", @@ -16829,35 +16838,17 @@ "react-dom": ">=16.8" } }, - "node_modules/react-shallow-renderer": { - "version": "16.15.0", - "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", - "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4.1.1", - "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/react-share": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/react-share/-/react-share-4.4.1.tgz", - "integrity": "sha512-AJ9m9RiJssqvYg7MoJUc9J0D7b/liWrsfQ99ndKc5vJ4oVHHd4Fy87jBlKEQPibT40oYA3AQ/a9/oQY6/yaigw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/react-share/-/react-share-5.2.2.tgz", + "integrity": "sha512-z0nbOX6X6vHHWAvXduNkYeJUKTKNpKM5Xpmc5a2BxjJhUWl+sE7AsSEMmYEUj2DuDjZr5m7KFIGF0sQPKcUN6w==", "license": "MIT", "dependencies": { "classnames": "^2.3.2", "jsonp": "^0.2.1" }, - "engines": { - "node": ">=6.9.0", - "npm": ">=5.0.0" - }, "peerDependencies": { - "react": "^16.3.0 || ^17 || ^18" + "react": "^17 || ^18 || ^19" } }, "node_modules/react-style-singleton": { @@ -16895,28 +16886,6 @@ "react": "^16.8.3 || ^17.0.0-0 || ^18.0.0" } }, - "node_modules/react-test-renderer": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz", - "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-is": "^18.3.1", - "react-shallow-renderer": "^16.15.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-test-renderer/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -17002,6 +16971,12 @@ "node": ">=6.0.0" } }, + "node_modules/recursive-readdir/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/recursive-readdir/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -17013,9 +16988,9 @@ } }, "node_modules/recursive-readdir/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -17047,46 +17022,11 @@ "balanced-match": "^1.0.0" } }, - "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==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, - "node_modules/redux-logger": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", - "integrity": "sha512-JoCIok7bg/XpqA1JqCqXFypuqBbQzGQySrhFzewB7ThcnysTO30l4VCst86AuB9T9tuT03MAA56Jw2PNhRSNCg==", - "license": "MIT", - "dependencies": { - "deep-diff": "^0.3.5" - } - }, - "node_modules/redux-mock-store": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.5.tgz", - "integrity": "sha512-YxX+ofKUTQkZE4HbhYG4kKGr7oCTJfB0GLy7bSeqx86GLpGirrbUWstMnqXkqHNaQpcnbMGbof2dYs5KsPE6Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.isplainobject": "^4.0.6" - }, - "peerDependencies": { - "redux": "*" - } - }, - "node_modules/redux-thunk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", - "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", - "license": "MIT", - "peerDependencies": { - "redux": "^4" - } + "node_modules/reduce-function-call/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" }, "node_modules/reflect-metadata": { "version": "0.2.2", @@ -17241,12 +17181,6 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, - "node_modules/reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -17883,12 +17817,6 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded/node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "license": "MIT" - }, "node_modules/sass-embedded/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -17944,11 +17872,14 @@ } } }, - "node_modules/sass/node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", - "license": "MIT" + "node_modules/sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/saxes": { "version": "6.0.0", @@ -17991,9 +17922,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -19073,18 +19004,18 @@ } }, "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", + "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "sax": "^1.5.0" }, "bin": { "svgo": "bin/svgo" @@ -19230,15 +19161,14 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", + "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -19331,6 +19261,12 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/test-exclude/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -19342,9 +19278,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -19885,15 +19821,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", - "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.55.0", - "@typescript-eslint/parser": "8.55.0", - "@typescript-eslint/typescript-estree": "8.55.0", - "@typescript-eslint/utils": "8.55.0" + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -19903,7 +19839,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -20191,15 +20127,6 @@ } } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -20347,9 +20274,9 @@ } }, "node_modules/webpack": { - "version": "5.105.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.1.tgz", - "integrity": "sha512-Gdj3X74CLJJ8zy4URmK42W7wTZUJrqL+z8nyGEr4dTN0kb3nVs+ZvjbTOqRYPD7qX4tUmwyHL9Q9K6T1seW6Yw==", + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", "peer": true, "dependencies": { @@ -20359,11 +20286,11 @@ "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -20375,9 +20302,9 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", + "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", - "webpack-sources": "^3.3.3" + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -20535,19 +20462,19 @@ } }, "node_modules/webpack-dev-middleware/node_modules/memfs": { - "version": "4.56.10", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.56.10.tgz", - "integrity": "sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w==", + "version": "4.56.11", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.56.11.tgz", + "integrity": "sha512-/GodtwVeKVIHZKLUSr2ZdOxKBC5hHki4JNCU22DoCGPEHr5o2PD5U721zvESKyWwCfTfavFl9WZYgA13OAYK0g==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.56.10", - "@jsonjoy.com/fs-fsa": "4.56.10", - "@jsonjoy.com/fs-node": "4.56.10", - "@jsonjoy.com/fs-node-builtins": "4.56.10", - "@jsonjoy.com/fs-node-to-fsa": "4.56.10", - "@jsonjoy.com/fs-node-utils": "4.56.10", - "@jsonjoy.com/fs-print": "4.56.10", - "@jsonjoy.com/fs-snapshot": "4.56.10", + "@jsonjoy.com/fs-core": "4.56.11", + "@jsonjoy.com/fs-fsa": "4.56.11", + "@jsonjoy.com/fs-node": "4.56.11", + "@jsonjoy.com/fs-node-builtins": "4.56.11", + "@jsonjoy.com/fs-node-to-fsa": "4.56.11", + "@jsonjoy.com/fs-node-utils": "4.56.11", + "@jsonjoy.com/fs-print": "4.56.11", + "@jsonjoy.com/fs-snapshot": "4.56.11", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", @@ -20810,9 +20737,9 @@ } }, "node_modules/webpack/node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", "engines": { "node": ">=10.13.0" @@ -21091,9 +21018,9 @@ } }, "node_modules/wsl-utils/node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" diff --git a/package.json b/package.json index e552788ff..8d5f962d7 100644 --- a/package.json +++ b/package.json @@ -46,45 +46,36 @@ "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/react-fontawesome": "^0.2.0", - "@redux-devtools/extension": "3.3.0", - "@reduxjs/toolkit": "^2.0.0", "classnames": "^2.3.1", - "filesize": "^10.0.0", "font-awesome": "4.7.0", "lodash": "^4.17.21", "moment": "^2.29.4", "prop-types": "15.8.1", - "react-share": "^4.4.0", - "redux-logger": "3.0.6", - "redux-thunk": "2.4.2", - "reselect": "^4.0.0" + "react-share": "^5.2.2" }, "devDependencies": { "@edx/browserslist-config": "^1.5.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/jest": "^29.5.14", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-expect-message": "^1.1.3", "jest-when": "^3.6.0", "react-dev-utils": "^12.0.0", - "react-test-renderer": "^18.3.1", - "redux-mock-store": "^1.5.4", "tsc-alias": "^1.8.16" }, "peerDependencies": { - "@openedx/frontend-base": "^1.0.0-alpha.13", + "@openedx/frontend-base": "^1.0.0-alpha.14", "@openedx/paragon": "^23", "@tanstack/react-query": "^5", "@types/react": "^18", "@types/react-dom": "^18", "react": "^18", "react-dom": "^18", - "react-redux": "^8", "react-router": "^6", - "react-router-dom": "^6", - "redux": "^4" + "react-router-dom": "^6" } } diff --git a/site.config.test.tsx b/site.config.test.tsx index 6f9da9078..99991595a 100644 --- a/site.config.test.tsx +++ b/site.config.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentTypes, SiteConfig } from '@openedx/frontend-base'; +import type { SiteConfig } from '@openedx/frontend-base'; import { appId } from './src/constants'; @@ -10,7 +10,9 @@ const siteConfig: SiteConfig = { loginUrl: 'http://localhost:8000/login', logoutUrl: 'http://localhost:8000/logout', - environment: EnvironmentTypes.TEST, + // Use 'test' instead of EnvironmentTypes.TEST to break a circular dependency + // when mocking `@openedx/frontend-base` itself. + environment: 'test' as SiteConfig['environment'], apps: [{ appId, config: { diff --git a/src/Main.jsx b/src/Main.jsx index 94233d2f9..3cafc12aa 100644 --- a/src/Main.jsx +++ b/src/Main.jsx @@ -1,19 +1,18 @@ -import { Provider as ReduxProvider } from 'react-redux'; import { CurrentAppProvider, PageWrap } from '@openedx/frontend-base'; import { appId } from './constants'; -import store from './data/store'; +import ContextProviders from './data/context'; import Dashboard from './containers/Dashboard'; import './app.scss'; const Main = () => ( - + - + ); diff --git a/src/app.ts b/src/app.ts index d82689bef..e294417b3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,7 @@ const app: App = { ECOMMERCE_BASE_URL: '', ORDER_HISTORY_URL: '', SUPPORT_URL: '', + SHOW_UNENROLL_SURVEY: false, } }; diff --git a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx index 19ee67481..f888bbf56 100644 --- a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.jsx @@ -1,22 +1,29 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; +import { EXECUTIVE_EDUCATION_COURSE_MODES } from '@src/data/constants/course'; -import track from '../../../../tracking'; -import { reduxHooks } from '../../../../hooks'; - +import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; export const BeginCourseButton = ({ cardId }) => { const { formatMessage } = useIntl(); - const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); - const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId); + const { data: learnerData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); + const homeUrl = courseData?.courseRun?.homeUrl; + const execEdTrackingParam = useMemo(() => { + const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode); + const { authOrgId } = learnerData.enterpriseDashboard || {}; + return isExecEd2UCourse ? `?org_id=${authOrgId}` : ''; + }, [courseData.enrollment.mode, learnerData.enterpriseDashboard]); const { disableBeginCourse } = useActionDisabledState(cardId); - const handleClick = reduxHooks.useTrackCourseEvent( + const handleClick = useCourseTrackingEvent( track.course.enterCourseClicked, cardId, homeUrl + execEdTrackingParam, diff --git a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.test.jsx index 7548be160..457b69a05 100644 --- a/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/BeginCourseButton.test.jsx @@ -1,36 +1,42 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; import useActionDisabledState from '../hooks'; import BeginCourseButton from './BeginCourseButton'; +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + enrollment: { mode: 'executive-education' }, + courseRun: { homeUrl: 'home-url' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + enterpriseDashboard: { + authOrgId: 'test-org-id', + }, + }, + }), +})); + jest.mock('@src/tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardExecEdTrackingParam: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, -})); - jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false }))); jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); const homeUrl = 'home-url'; -reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl }); -const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`; -reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath); -reduxHooks.useTrackCourseEvent.mockImplementation( - (eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }), -); const props = { cardId: 'cardId', @@ -45,11 +51,7 @@ describe('BeginCourseButton', () => { describe('initiliaze hooks', () => { it('initializes course run data with cardId', () => { renderComponent(); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); - }); - it('loads exec education path param', () => { - renderComponent(); - expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId); + expect(useCourseData).toHaveBeenCalledWith(props.cardId); }); it('loads disabled states for begin action from action hooks', () => { renderComponent(); @@ -73,15 +75,15 @@ describe('BeginCourseButton', () => { expect(button).not.toHaveClass('disabled'); expect(button).not.toHaveAttribute('aria-disabled', 'true'); }); - it('should track enter course clicked event on click, with exec ed param', async () => { + it('should track enter course clicked event on click, with exec ed param', () => { renderComponent(); const user = userEvent.setup(); const button = screen.getByRole('button', { name: 'Begin Course' }); user.click(button); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.enterCourseClicked, props.cardId, - homeUrl + execEdPath(props.cardId), + `${homeUrl}?org_id=test-org-id`, ); }); }); diff --git a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx index 12fb56a3f..9df845da0 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.jsx @@ -1,22 +1,29 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import track from '../../../../tracking'; -import { reduxHooks } from '../../../../hooks'; - +import { EXECUTIVE_EDUCATION_COURSE_MODES } from '@src/data/constants/course'; +import track from '@src/tracking'; +import { useCourseTrackingEvent, useCourseData } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; export const ResumeButton = ({ cardId }) => { const { formatMessage } = useIntl(); - const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId); - const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId); + const { data: learnerData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); + const resumeUrl = courseData?.courseRun?.resumeUrl; + const execEdTrackingParam = useMemo(() => { + const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode); + const { authOrgId } = learnerData.enterpriseDashboard || {}; + return isExecEd2UCourse ? `?org_id=${authOrgId}` : ''; + }, [courseData.enrollment.mode, learnerData.enterpriseDashboard]); const { disableResumeCourse } = useActionDisabledState(cardId); - const handleClick = reduxHooks.useTrackCourseEvent( + const handleClick = useCourseTrackingEvent( track.course.enterCourseClicked, cardId, resumeUrl + execEdTrackingParam, diff --git a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.test.jsx index ad3515a7e..da407402f 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ResumeButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ResumeButton.test.jsx @@ -1,36 +1,47 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@openedx/frontend-base'; +import { useCourseTrackingEvent, useCourseData } from '@src/hooks'; -import { reduxHooks } from '@src/hooks'; import track from '@src/tracking'; import useActionDisabledState from '../hooks'; import ResumeButton from './ResumeButton'; +const authOrgId = 'auth-org-id'; +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + enterpriseDashboard: { + authOrgId, + }, + }, + }), +})); + +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + enrollment: { mode: 'executive-education' }, + courseRun: { homeUrl: 'home-url' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + jest.mock('@src/tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardExecEdTrackingParam: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, -})); jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false }))); jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); -const resumeUrl = 'resume-url'; -reduxHooks.useCardCourseRunData.mockReturnValue({ resumeUrl }); -const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`; -reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath); -reduxHooks.useTrackCourseEvent.mockImplementation( - (eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }), -); +useCourseData.mockReturnValue({ + enrollment: { mode: 'executive-education' }, + courseRun: { resumeUrl: 'home-url' }, +}); describe('ResumeButton', () => { const props = { @@ -39,10 +50,7 @@ describe('ResumeButton', () => { describe('initialize hooks', () => { beforeEach(() => render()); it('initializes course run data with cardId', () => { - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); - }); - it('loads exec education path param', () => { - expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId); + expect(useCourseData).toHaveBeenCalledWith(props.cardId); }); it('loads disabled states for resume action from action hooks', () => { expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId); @@ -73,10 +81,10 @@ describe('ResumeButton', () => { const user = userEvent.setup(); const button = screen.getByRole('button', { name: 'Resume' }); user.click(button); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.enterCourseClicked, props.cardId, - resumeUrl + execEdPath(props.cardId), + `home-url?org_id=${authOrgId}`, ); }); }); diff --git a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx index f4283dc61..6e11434f0 100644 --- a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.jsx @@ -3,8 +3,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import { reduxHooks } from '../../../../hooks'; - +import { useSelectSessionModal } from '@src/data/context'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; @@ -12,11 +11,11 @@ import messages from './messages'; export const SelectSessionButton = ({ cardId }) => { const { formatMessage } = useIntl(); const { disableSelectSession } = useActionDisabledState(cardId); - const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); + const { updateSelectSessionModal } = useSelectSessionModal(); return ( updateSelectSessionModal(cardId)} > {formatMessage(messages.selectSession)} diff --git a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.test.jsx index 45bdd2c24..edbce2241 100644 --- a/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/SelectSessionButton.test.jsx @@ -1,16 +1,16 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@openedx/frontend-base'; +import { useSelectSessionModal } from '@src/data/context'; -import { reduxHooks } from '@src/hooks'; import useActionDisabledState from '../hooks'; import SelectSessionButton from './SelectSessionButton'; -jest.mock('@src/hooks', () => ({ - reduxHooks: { - useUpdateSelectSessionModalCallback: jest.fn(), - }, +jest.mock('@src/data/context', () => ({ + useSelectSessionModal: jest.fn().mockReturnValue({ + updateSelectSessionModal: jest.fn(), + }), })); jest.mock('../hooks', () => jest.fn(() => ({ disableSelectSession: false }))); @@ -33,11 +33,15 @@ describe('SelectSessionButton', () => { }); describe('on click', () => { it('should call openSessionModal', async () => { + const mockedUpdateSelectSessionModal = jest.fn(); + useSelectSessionModal.mockReturnValue({ + updateSelectSessionModal: mockedUpdateSelectSessionModal, + }); render(); const user = userEvent.setup(); const button = screen.getByRole('button', { name: 'Select Session' }); await user.click(button); - expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(props.cardId); + expect(mockedUpdateSelectSessionModal).toHaveBeenCalledWith(props.cardId); }); }); }); diff --git a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx index 5606689bf..888ae059a 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.jsx @@ -3,19 +3,19 @@ import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import track from '../../../../tracking'; -import { reduxHooks } from '../../../../hooks'; - +import track from '@src/tracking'; +import { useCourseTrackingEvent, useCourseData } from '@src/hooks'; import useActionDisabledState from '../hooks'; import ActionButton from './ActionButton'; import messages from './messages'; export const ViewCourseButton = ({ cardId }) => { const { formatMessage } = useIntl(); - const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); + const courseData = useCourseData(cardId); + const homeUrl = courseData?.courseRun?.homeUrl; const { disableViewCourse } = useActionDisabledState(cardId); - const handleClick = reduxHooks.useTrackCourseEvent( + const handleClick = useCourseTrackingEvent( track.course.enterCourseClicked, cardId, homeUrl, diff --git a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx index 3666ecad6..73694b9b2 100644 --- a/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/ViewCourseButton.test.jsx @@ -1,24 +1,27 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@openedx/frontend-base'; +import { useCourseTrackingEvent } from '@src/hooks'; import track from '@src/tracking'; -import { reduxHooks } from '@src/hooks'; import useActionDisabledState from '../hooks'; import ViewCourseButton from './ViewCourseButton'; +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn().mockReturnValue({ + courseRun: { homeUrl: 'homeUrl' }, + }), + useCourseTrackingEvent: jest.fn().mockReturnValue({ + trackCourseEvent: jest.fn(), + }), +})); + jest.mock('@src/tracking', () => ({ course: { enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'), }, })); -jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(() => ({ homeUrl: 'homeUrl' })), - useTrackCourseEvent: jest.fn(), - }, -})); jest.mock('../hooks', () => jest.fn(() => ({ disableViewCourse: false }))); jest.mock('./ActionButton/hooks', () => jest.fn(() => false)); @@ -35,15 +38,18 @@ describe('ViewCourseButton', () => { expect(button).not.toHaveAttribute('aria-disabled', 'true'); }); it('calls trackCourseEvent on click', async () => { + const mockedTrackCourseEvent = jest.fn(); + useCourseTrackingEvent.mockReturnValue(mockedTrackCourseEvent); render(); const user = userEvent.setup(); const button = screen.getByRole('button', { name: 'View Course' }); await user.click(button); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.enterCourseClicked, defaultProps.cardId, homeUrl, ); + expect(mockedTrackCourseEvent).toHaveBeenCalled(); }); it('learner cannot view course', () => { useActionDisabledState.mockReturnValueOnce({ disableViewCourse: true }); diff --git a/src/containers/CourseCard/components/CourseCardActions/index.jsx b/src/containers/CourseCard/components/CourseCardActions/index.jsx index 50a08e69b..21d168951 100644 --- a/src/containers/CourseCard/components/CourseCardActions/index.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/index.jsx @@ -3,20 +3,19 @@ import PropTypes from 'prop-types'; import { ActionRow } from '@openedx/paragon'; -import { reduxHooks } from '../../../../hooks'; -import CourseCardActionSlot from '../../../../slots/CourseCardActionSlot'; +import { useCourseData, useEntitlementInfo } from '@src/hooks'; +import CourseCardActionSlot from '@src/slots/CourseCardActionSlot'; import SelectSessionButton from './SelectSessionButton'; import BeginCourseButton from './BeginCourseButton'; import ResumeButton from './ResumeButton'; import ViewCourseButton from './ViewCourseButton'; export const CourseCardActions = ({ cardId }) => { - const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId); - const { - hasStarted, - } = reduxHooks.useCardEnrollmentData(cardId); - const { isArchived } = reduxHooks.useCardCourseRunData(cardId); + const cardData = useCourseData(cardId); + const hasStarted = cardData.enrollment.hasStarted || false; + const { isEntitlement, isFulfilled } = useEntitlementInfo(cardData); + const isArchived = cardData.courseRun.isArchived || false; return ( diff --git a/src/containers/CourseCard/components/CourseCardActions/index.test.jsx b/src/containers/CourseCard/components/CourseCardActions/index.test.jsx index 5b133444c..7a02de45b 100644 --- a/src/containers/CourseCard/components/CourseCardActions/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardActions/index.test.jsx @@ -1,15 +1,10 @@ import { render, screen } from '@testing-library/react'; -import { reduxHooks } from '@src/hooks'; - +import { useCourseData } from '@src/hooks'; import CourseCardActions from '.'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardEntitlementData: jest.fn(), - useMasqueradeData: jest.fn(), - }, + ...jest.requireActual('@src/hooks'), + useCourseData: jest.fn(), })); jest.mock('@src/slots/CourseCardActionSlot', () => jest.fn(() =>
CourseCardActionSlot
)); @@ -24,26 +19,22 @@ const props = { cardId }; describe('CourseCardActions', () => { const mockHooks = ({ isEntitlement = false, - isExecEd2UCourse = false, isFulfilled = false, isArchived = false, - isVerified = false, hasStarted = false, - isMasquerading = false, } = {}) => { - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled }); - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isExecEd2UCourse, isVerified, hasStarted }); - reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading }); + useCourseData.mockReturnValueOnce({ + enrollment: { hasStarted }, + courseRun: { isArchived }, + entitlement: isEntitlement !== null ? { isEntitlement, isFulfilled } : null, + }); }; const renderComponent = () => render(); describe('hooks', () => { - it('initializes redux hooks', () => { + it('initializes hooks', () => { mockHooks(); renderComponent(); - expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('output', () => { @@ -63,7 +54,7 @@ describe('CourseCardActions', () => { }); describe('not entitlement, verified, or exec ed', () => { it('renders CourseCardActionSlot and ViewCourseButton for archived courses', () => { - mockHooks({ isArchived: true }); + mockHooks({ isArchived: true, isEntitlement: null }); renderComponent(); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); expect(CourseCardActionSlot).toBeInTheDocument(); @@ -72,7 +63,7 @@ describe('CourseCardActions', () => { }); describe('unstarted courses', () => { it('renders CourseCardActionSlot and BeginCourseButton', () => { - mockHooks(); + mockHooks({ isEntitlement: null }); renderComponent(); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); expect(CourseCardActionSlot).toBeInTheDocument(); @@ -82,7 +73,7 @@ describe('CourseCardActions', () => { }); describe('active courses (started, and not archived)', () => { it('renders CourseCardActionSlot and ResumeButton', () => { - mockHooks({ hasStarted: true }); + mockHooks({ hasStarted: true, isEntitlement: null }); renderComponent(); const CourseCardActionSlot = screen.getByText('CourseCardActionSlot'); expect(CourseCardActionSlot).toBeInTheDocument(); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx index 59a6a1fe1..e5491d19d 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.jsx @@ -1,28 +1,47 @@ /* eslint-disable max-len */ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { MailtoLink, Hyperlink } from '@openedx/paragon'; import { CheckCircle } from '@openedx/paragon/icons'; import { useIntl } from '@openedx/frontend-base'; +import { baseAppUrl } from '@src/data/services/lms/urls'; -import { utilHooks, reduxHooks } from '../../../../hooks'; -import Banner from '../../../../components/Banner'; +import { useInitializeLearnerHome } from '@src/data/hooks'; +import { utilHooks, useCourseData } from '@src/hooks'; +import Banner from '@src/components/Banner'; import messages from './messages'; const { useFormatDate } = utilHooks; export const CertificateBanner = ({ cardId }) => { - const certificate = reduxHooks.useCardCertificateData(cardId); + const { data: learnerHomeData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); const { - isAudit, - isVerified, - } = reduxHooks.useCardEnrollmentData(cardId); - const { isPassing } = reduxHooks.useCardGradeData(cardId); - const { isArchived } = reduxHooks.useCardCourseRunData(cardId); - const { minPassingGrade, progressUrl } = reduxHooks.useCardCourseRunData(cardId); - const { supportEmail, billingEmail } = reduxHooks.usePlatformSettingsData(); + certificate = {}, + isVerified = false, + isAudit = false, + isPassing = false, + isArchived = false, + minPassingGrade = 0, + progressUrl = '', + } = useMemo(() => ({ + isVerified: courseData?.enrollment?.isVerified, + isAudit: courseData?.enrollment?.isAudit, + certificate: courseData?.certificate || {}, + isPassing: courseData?.gradeData?.isPassing, + isArchived: courseData?.courseRun?.isArchived, + minPassingGrade: Math.floor((courseData?.courseRun?.minPassingGrade ?? 0) * 100), + progressUrl: baseAppUrl(courseData?.courseRun?.progressUrl || ''), + }), [courseData]); + const { supportEmail, billingEmail } = useMemo( + () => ({ + supportEmail: learnerHomeData?.platformSettings?.supportEmail, + billingEmail: learnerHomeData?.platformSettings?.billingEmail, + }), + [learnerHomeData], + ); const { formatMessage } = useIntl(); const formatDate = useFormatDate(); @@ -31,7 +50,7 @@ export const CertificateBanner = ({ cardId }) => { if (certificate.isRestricted) { return ( - {supportEmail ? formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) }) : formatMessage(messages.certRestrictedNoEmail)} + { supportEmail ? formatMessage(messages.certRestricted, { supportEmail: emailLink(supportEmail) }) : formatMessage(messages.certRestrictedNoEmail)} {isVerified && ' '} {isVerified && (billingEmail ? formatMessage(messages.certRefundContactBilling, { billingEmail: emailLink(billingEmail) }) : formatMessage(messages.certRefundContactBillingNoEmail))} @@ -75,7 +94,7 @@ export const CertificateBanner = ({ cardId }) => { ); } - if (certificate.isEarnedButUnavailable) { + if (certificate.isEarned && new Date(certificate.availableDate) > new Date()) { return ( {formatMessage( diff --git a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.test.jsx index 452cd0dd4..021ce1198 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CertificateBanner.test.jsx @@ -1,20 +1,20 @@ +import React from 'react'; import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import CertificateBanner from './CertificateBanner'; jest.mock('@src/hooks', () => ({ utilHooks: { useFormatDate: jest.fn(() => date => date), }, - reduxHooks: { - useCardCertificateData: jest.fn(), - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardGradeData: jest.fn(), - usePlatformSettingsData: jest.fn(), - }, + useCourseData: jest.fn(), +})); + +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), })); const defaultCertificate = { @@ -35,9 +35,14 @@ const supportEmail = 'suport@email.com'; const billingEmail = 'billing@email.com'; describe('CertificateBanner', () => { - reduxHooks.useCardCourseRunData.mockReturnValue({ - minPassingGrade: 0.8, - progressUrl: 'progressUrl', + useCourseData.mockReturnValue({ + enrollment: {}, + certificate: {}, + gradeData: {}, + courseRun: { + minPassingGrade: 0.8, + progressUrl: 'progressUrl', + }, }); const createWrapper = ({ certificate = {}, @@ -46,11 +51,17 @@ describe('CertificateBanner', () => { courseRun = {}, platformSettings = {}, }) => { - reduxHooks.useCardGradeData.mockReturnValueOnce({ ...defaultGrade, ...grade }); - reduxHooks.useCardCertificateData.mockReturnValueOnce({ ...defaultCertificate, ...certificate }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ ...defaultEnrollment, ...enrollment }); - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ ...defaultCourseRun, ...courseRun }); - reduxHooks.usePlatformSettingsData.mockReturnValueOnce({ ...defaultPlatformSettings, ...platformSettings }); + useCourseData.mockReturnValue({ + enrollment: { ...defaultEnrollment, ...enrollment }, + certificate: { ...defaultCertificate, ...certificate }, + gradeData: { ...defaultGrade, ...grade }, + courseRun: { + ...defaultCourseRun, + ...courseRun, + }, + }); + const lernearData = { data: { platformSettings: { ...defaultPlatformSettings, ...platformSettings } } }; + useInitializeLearnerHome.mockReturnValue(lernearData); return render(); }; beforeEach(() => { @@ -222,7 +233,8 @@ describe('CertificateBanner', () => { isPassing: true, }, certificate: { - isEarnedButUnavailable: true, + isEarned: true, + availableDate: '10/20/3030', }, }); const banner = screen.getByRole('alert'); @@ -239,4 +251,27 @@ describe('CertificateBanner', () => { const banner = screen.queryByRole('alert'); expect(banner).toBeNull(); }); + it('should use default values when courseData is empty or undefined', () => { + useCourseData.mockReturnValue({}); + const lernearData = { data: { platformSettings: { supportEmail } } }; + useInitializeLearnerHome.mockReturnValue(lernearData); + render(); + + const mockedUseMemo = jest.spyOn(React, 'useMemo'); + const useMemoCall = mockedUseMemo.mock.calls.find(call => call[1].some(dep => dep === undefined || dep === null)); + + if (useMemoCall) { + const result = useMemoCall[0](); + + expect(result.certificate).toEqual({}); + expect(result.isVerified).toBe(false); + expect(result.isAudit).toBe(false); + expect(result.isPassing).toBe(false); + expect(result.isArchived).toBe(false); + expect(result.minPassingGrade).toBe(0); + expect(result.progressUrl).toBeDefined(); + } + + mockedUseMemo.mockRestore(); + }); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx index 266c37532..f06310801 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.jsx @@ -1,22 +1,26 @@ /* eslint-disable max-len */ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { Hyperlink } from '@openedx/paragon'; import { useIntl } from '@openedx/frontend-base'; -import { utilHooks, reduxHooks } from '../../../../hooks'; -import Banner from '../../../../components/Banner'; - +import { utilHooks, useCourseData } from '@src/hooks'; +import Banner from '@src/components/Banner'; import messages from './messages'; export const CourseBanner = ({ cardId }) => { + const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); const { - isVerified, - isAuditAccessExpired, + isVerified = false, + isAuditAccessExpired = false, coursewareAccess = {}, - } = reduxHooks.useCardEnrollmentData(cardId); - const courseRun = reduxHooks.useCardCourseRunData(cardId); - const { formatMessage } = useIntl(); + } = useMemo(() => ({ + isVerified: courseData.enrollment?.isVerified, + isAuditAccessExpired: courseData.enrollment?.isAuditAccessExpired, + coursewareAccess: courseData.enrollment?.coursewareAccess || {}, + }), [courseData]); + const courseRun = courseData?.courseRun || {}; const formatDate = utilHooks.useFormatDate(); const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx index 077681739..4bd2747de 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CourseBanner.test.jsx @@ -1,20 +1,17 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import { formatMessage } from '@src/testUtils'; import { CourseBanner } from './CourseBanner'; import messages from './messages'; jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn(), utilHooks: { useFormatDate: () => date => date, }, - reduxHooks: { - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - }, })); const cardId = 'test-card-id'; @@ -39,27 +36,36 @@ const renderCourseBanner = (overrides = {}) => { courseRun = {}, enrollment = {}, } = overrides; - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ - ...courseRunData, - ...courseRun, - }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - ...enrollmentData, - ...enrollment, + useCourseData.mockReturnValue({ + courseRun: { + ...courseRunData, + ...courseRun, + }, + enrollment: { + ...enrollmentData, + ...enrollment, + }, }); return render(); }; describe('CourseBanner', () => { - it('initializes data with course number from enrollment, course and course run data', () => { + it('calls useCourseData with the correct cardId', () => { renderCourseBanner(); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); it('no display if learner is verified', () => { renderCourseBanner({ enrollment: { isVerified: true } }); expect(screen.queryByRole('alert')).toBeNull(); }); + it('should use default values when enrollment data is undefined', () => { + renderCourseBanner({ + enrollment: undefined, + courseRun: {}, + }); + + expect(useCourseData).toHaveBeenCalledWith('test-card-id'); + }); describe('audit access expired', () => { it('should display correct message and link', () => { renderCourseBanner({ enrollment: { isAuditAccessExpired: true } }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js index 0f4ff0640..296f885ec 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.js @@ -1,5 +1,8 @@ -import { StrictDict } from '../../../../../utils'; -import { reduxHooks } from '../../../../../hooks'; +import { useMemo } from 'react'; +import { useInitializeLearnerHome } from '@src/data/hooks'; +import { StrictDict } from '@src/utils'; + +import { useCourseData } from '@src/hooks'; import ApprovedContent from './views/ApprovedContent'; import EligibleContent from './views/EligibleContent'; @@ -14,9 +17,29 @@ export const statusComponents = StrictDict({ }); export const useCreditBannerData = (cardId) => { - const credit = reduxHooks.useCardCreditData(cardId); - const { supportEmail } = reduxHooks.usePlatformSettingsData(); - if (!credit.isEligible) { + const courseData = useCourseData(cardId); + const { data: learnerHomeData } = useInitializeLearnerHome(); + const supportEmail = useMemo( + () => (learnerHomeData?.platformSettings?.supportEmail), + [learnerHomeData], + ); + + const credit = useMemo(() => { + const creditData = courseData?.credit; + if (!creditData || Object.keys(creditData).length === 0) { + return { isEligible: false }; + } + return { + isEligible: true, + providerStatusUrl: creditData.providerStatusUrl, + providerName: creditData.providerName, + providerId: creditData.providerId, + error: creditData.error, + purchased: creditData.purchased, + requestStatus: creditData.requestStatus, + }; + }, [courseData]); + if (!credit.isEligible || !courseData?.credit?.isEligible) { return null; } diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js index b73ec4c03..1aeaf7dc6 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/hooks.test.js @@ -1,5 +1,6 @@ import { keyStore } from '@src/utils'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import ApprovedContent from './views/ApprovedContent'; import EligibleContent from './views/EligibleContent'; @@ -9,12 +10,19 @@ import RejectedContent from './views/RejectedContent'; import * as hooks from './hooks'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - usePlatformSettingsData: jest.fn(), - }, + useCourseData: jest.fn(), })); + +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), +})); + jest.mock('./views/ApprovedContent', () => 'ApprovedContent'); jest.mock('./views/EligibleContent', () => 'EligibleContent'); jest.mock('./views/MustRequestContent', () => 'MustRequestContent'); @@ -34,18 +42,18 @@ const defaultProps = { }; const loadHook = (creditData = {}) => { - reduxHooks.useCardCreditData.mockReturnValue({ ...defaultProps, ...creditData }); + useCourseData.mockReturnValue({ credit: { ...defaultProps, ...creditData } }); out = hooks.useCreditBannerData(cardId); }; describe('useCreditBannerData hook', () => { beforeEach(() => { - reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail }); + useInitializeLearnerHome.mockReturnValue({ data: { platformSettings: { supportEmail } } }); }); it('loads card credit data with cardID and loads platform settings data', () => { loadHook({ isEligible: false }); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.usePlatformSettingsData).toHaveBeenCalledWith(); + expect(useCourseData).toHaveBeenCalledWith(cardId); + expect(useInitializeLearnerHome).toHaveBeenCalledWith(); }); describe('non-credit-eligible learner', () => { it('returns null if the learner is not credit eligible', () => { diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx index 1f6143d6b..9df635f68 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/ApprovedContent.jsx @@ -1,17 +1,24 @@ +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { useContext } from 'react'; + import { useIntl } from '@openedx/frontend-base'; -import MasqueradeUserContext from '../../../../../../data/contexts/MasqueradeUserContext'; -import { reduxHooks } from '../../../../../../hooks'; +import { useCourseData, useIsMasquerading } from '@src/hooks'; import CreditContent from './components/CreditContent'; import ProviderLink from './components/ProviderLink'; import messages from './messages'; export const ApprovedContent = ({ cardId }) => { - const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId); - const { isMasquerading } = useContext(MasqueradeUserContext); + const courseData = useCourseData(cardId); + const { providerStatusUrl: href, providerName } = useMemo(() => { + const creditData = courseData?.credit; + return { + providerStatusUrl: creditData.providerStatusUrl, + providerName: creditData.providerName, + }; + }, [courseData]); + const isMasquerading = useIsMasquerading(); const { formatMessage } = useIntl(); return ( ({ - reduxHooks: { - useCardCreditData: jest.fn(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'test-card-id'; @@ -17,28 +15,21 @@ const credit = { providerStatusUrl: 'test-credit-provider-status-url', providerName: 'test-credit-provider-name', }; -reduxHooks.useCardCreditData.mockReturnValue(credit); - -const renderWithMasquerading = (isMasquerading = false) => render( - - - - - -); +useCourseData.mockReturnValue({ credit }); +useIsMasquerading.mockReturnValue(false); describe('ApprovedContent component', () => { describe('hooks', () => { it('initializes credit data with cardId', () => { - renderWithMasquerading(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + render(); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('render', () => { describe('rendered CreditContent component', () => { beforeEach(() => { jest.clearAllMocks(); - renderWithMasquerading(); + render(); }); it('action.message is formatted viewCredit message', () => { const actionButton = screen.getByRole('link', { name: messages.viewCredit.defaultMessage }); @@ -63,7 +54,8 @@ describe('ApprovedContent component', () => { }); describe('when masquerading', () => { beforeEach(() => { - renderWithMasquerading(true); + useIsMasquerading.mockReturnValue(true); + render(); }); it('disables the action button', () => { diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx index 24c40eff2..f156e1c7b 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.jsx @@ -3,16 +3,17 @@ import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import { reduxHooks } from '../../../../../../hooks'; -import track from '../../../../../../tracking'; +import { useCourseData } from '@src/hooks'; +import track from '@src/tracking'; import CreditContent from './components/CreditContent'; import messages from './messages'; export const EligibleContent = ({ cardId }) => { const { formatMessage } = useIntl(); - const { providerName } = reduxHooks.useCardCreditData(cardId); - const { courseId } = reduxHooks.useCardCourseRunData(cardId); + const courseData = useCourseData(cardId); + const providerName = courseData?.credit?.providerName; + const courseId = courseData?.courseRun?.courseId; const onClick = track.credit.purchase(courseId); const getCredit = formatMessage(messages.getCredit); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx index 067236ce3..8360c4d8a 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/EligibleContent.test.jsx @@ -2,17 +2,14 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import track from '@src/tracking'; import messages from './messages'; import EligibleContent from './EligibleContent'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - useCardCourseRunData: jest.fn(), - }, + useCourseData: jest.fn(), })); jest.mock('@src/tracking', () => ({ @@ -26,8 +23,7 @@ const courseId = 'test-course-id'; const credit = { providerName: 'test-credit-provider-name', }; -reduxHooks.useCardCreditData.mockReturnValue(credit); -reduxHooks.useCardCourseRunData.mockReturnValue({ courseId }); +useCourseData.mockReturnValue({ credit, courseRun: { courseId } }); const renderEligibleContent = () => render(); @@ -35,11 +31,7 @@ describe('EligibleContent component', () => { describe('hooks', () => { it('initializes credit data with cardId', () => { renderEligibleContent(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); - }); - it('initializes course run data with cardId', () => { - renderEligibleContent(); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('behavior', () => { @@ -63,7 +55,7 @@ describe('EligibleContent component', () => { expect(eligibleMessage).toHaveTextContent(credit.providerName); }); it('message is formatted eligible message if no provider', () => { - reduxHooks.useCardCreditData.mockReturnValue({}); + useCourseData.mockReturnValue({ credit: {}, courseRun: { courseId } }); renderEligibleContent(); const eligibleMessage = screen.getByTestId('credit-msg'); expect(eligibleMessage).toBeInTheDocument(); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx index ea61b9335..e849e7e03 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/MustRequestContent.jsx @@ -1,8 +1,9 @@ +import React from 'react'; import PropTypes from 'prop-types'; -import { useContext } from 'react'; + import { useIntl } from '@openedx/frontend-base'; -import MasqueradeUserContext from '../../../../../../data/contexts/MasqueradeUserContext'; +import { useIsMasquerading } from '@src/hooks'; import CreditContent from './components/CreditContent'; import ProviderLink from './components/ProviderLink'; import hooks from './hooks'; @@ -12,7 +13,7 @@ import messages from './messages'; export const MustRequestContent = ({ cardId }) => { const { formatMessage } = useIntl(); const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId); - const { isMasquerading } = useContext(MasqueradeUserContext); + const isMasquerading = useIsMasquerading(); return ( ({ })); jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'test-card-id'; @@ -31,11 +28,9 @@ const providerName = 'test-credit-provider-name'; const providerStatusUrl = 'test-credit-provider-status-url'; const createCreditRequest = jest.fn().mockName('createCreditRequest'); -const renderMustRequestContent = (isMasquerading = false) => render( +const renderMustRequestContent = () => render( - - - + , ); @@ -46,9 +41,12 @@ describe('MustRequestContent component', () => { requestData, createCreditRequest, }); - reduxHooks.useCardCreditData.mockReturnValue({ - providerName, - providerStatusUrl, + useIsMasquerading.mockReturnValue(false); + useCourseData.mockReturnValue({ + credit: { + providerName, + providerStatusUrl, + }, }); }); @@ -91,13 +89,14 @@ describe('MustRequestContent component', () => { describe('when masquerading', () => { beforeEach(() => { - renderMustRequestContent(true); + useIsMasquerading.mockReturnValue(true); + renderMustRequestContent(); }); it('disables the request credit button', () => { const button = screen.getByRole('button', { name: /request credit/i }); - expect(button).toHaveAttribute('aria-disabled', 'true'); expect(button).toHaveClass('disabled'); + expect(button).toHaveAttribute('aria-disabled', 'true'); }); }); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx index b0ff47836..b9c72fd30 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/PendingContent.jsx @@ -1,15 +1,16 @@ +import React from 'react'; import PropTypes from 'prop-types'; -import { useContext } from 'react'; + import { useIntl } from '@openedx/frontend-base'; -import MasqueradeUserContext from '../../../../../../data/contexts/MasqueradeUserContext'; -import { reduxHooks } from '../../../../../../hooks'; +import { useCourseData, useIsMasquerading } from '@src/hooks'; import CreditContent from './components/CreditContent'; import messages from './messages'; export const PendingContent = ({ cardId }) => { - const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId); - const { isMasquerading } = useContext(MasqueradeUserContext); + const courseData = useCourseData(cardId); + const { providerStatusUrl: href, providerName } = courseData?.credit || {}; + const isMasquerading = useIsMasquerading(); const { formatMessage } = useIntl(); return ( ({ - reduxHooks: { useCardCreditData: jest.fn() }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'test-card-id'; const providerName = 'test-credit-provider-name'; const providerStatusUrl = 'test-credit-provider-status-url'; -reduxHooks.useCardCreditData.mockReturnValue({ - providerName, - providerStatusUrl, +useIsMasquerading.mockReturnValue(false); +useCourseData.mockReturnValue({ + credit: { + providerName, + providerStatusUrl, + }, }); -const renderPendingContent = (isMasquerading = false) => render( +const renderPendingContent = () => render( - - - + , ); describe('PendingContent component', () => { describe('hooks', () => { it('initializes card credit data with cardId', () => { renderPendingContent(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('behavior', () => { @@ -58,7 +58,8 @@ describe('PendingContent component', () => { }); describe('when masqueradeData is true', () => { it('disables the view details button', () => { - renderPendingContent(true); + useIsMasquerading.mockReturnValue(true); + renderPendingContent(); const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage }); expect(button).toHaveAttribute('aria-disabled', 'true'); expect(button).toHaveClass('disabled'); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx index 27c747baf..9b9abb205 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.jsx @@ -3,18 +3,19 @@ import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; -import { reduxHooks } from '../../../../../../hooks'; +import { useCourseData } from '@src/hooks'; import CreditContent from './components/CreditContent'; import ProviderLink from './components/ProviderLink'; import messages from './messages'; export const RejectedContent = ({ cardId }) => { - const credit = reduxHooks.useCardCreditData(cardId); + const courseData = useCourseData(cardId); + const credit = courseData?.credit; const { formatMessage } = useIntl(); return ( ), })} /> diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx index 8eb08962e..ce0877477 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/RejectedContent.test.jsx @@ -1,13 +1,11 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import RejectedContent from './RejectedContent'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCreditData: jest.fn(), - }, + useCourseData: jest.fn(), })); const cardId = 'test-card-id'; @@ -15,7 +13,9 @@ const credit = { providerStatusUrl: 'test-credit-provider-status-url', providerName: 'test-credit-provider-name', }; -reduxHooks.useCardCreditData.mockReturnValue(credit); +useCourseData.mockReturnValue({ + credit, +}); const renderRejectedContent = () => render(); @@ -23,7 +23,7 @@ describe('RejectedContent component', () => { describe('hooks', () => { it('initializes credit data with cardId', () => { renderRejectedContent(); - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('render', () => { diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx index 5bc7467bf..f1ac52717 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/components/ProviderLink.jsx @@ -2,11 +2,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { reduxHooks } from '../../../../../../../hooks'; +import { useCourseData } from '@src/hooks'; import { Hyperlink } from '@openedx/paragon'; export const ProviderLink = ({ cardId }) => { - const credit = reduxHooks.useCardCreditData(cardId); + const courseData = useCourseData(cardId); + const credit = courseData?.credit || {}; return ( ({ - reduxHooks: { - useCardCreditData: jest.fn(), - }, + useCourseData: jest.fn(), })); const cardId = 'test-card-id'; @@ -23,12 +21,12 @@ const renderProviderLink = () => render( describe('ProviderLink component', () => { beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useCardCreditData.mockReturnValue(credit); + useCourseData.mockReturnValue({ credit }); renderProviderLink(); }); describe('hooks', () => { it('initializes credit hook with cardId', () => { - expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); }); describe('render', () => { diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js index d0eff0b84..90f73656b 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.js @@ -1,7 +1,8 @@ import React from 'react'; - -import { StrictDict } from '../../../../../../utils'; -import { apiHooks } from '../../../../../../hooks'; +import { useAuthenticatedUser } from '@openedx/frontend-base'; +import { StrictDict } from '@src/utils'; +import { useCourseData } from '@src/hooks'; +import { useCreateCreditRequest } from '@src/data/hooks'; import * as module from './hooks'; @@ -11,13 +12,19 @@ export const state = StrictDict({ export const useCreditRequestData = (cardId) => { const [requestData, setRequestData] = module.state.creditRequestData(null); - const createCreditApiRequest = apiHooks.useCreateCreditRequest(cardId); + const courseData = useCourseData(cardId); + const providerId = courseData?.credit?.providerId; + const { username } = useAuthenticatedUser(); + const courseId = courseData?.courseRun?.courseId; + const { mutate: createCreditMutation } = useCreateCreditRequest(); + const createCreditRequest = (e) => { e.preventDefault(); - createCreditApiRequest() - .then((request) => { - setRequestData(request.data); - }); + createCreditMutation({ providerId, courseId, username }, { + onSuccess: (response) => { + setRequestData(response.data); + }, + }); }; return { requestData, createCreditRequest }; }; diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js deleted file mode 100644 index 4d2e6852b..000000000 --- a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { MockUseState } from '@src/testUtils'; -import { apiHooks } from '@src/hooks'; -import * as hooks from './hooks'; - -jest.mock('@src/hooks', () => ({ - apiHooks: { - useCreateCreditRequest: jest.fn(), - }, -})); - -const state = new MockUseState(hooks); - -const cardId = 'test-card-id'; -const requestData = { data: 'request data' }; -const creditRequest = jest.fn().mockReturnValue(Promise.resolve(requestData)); -apiHooks.useCreateCreditRequest.mockReturnValue(creditRequest); -const event = { preventDefault: jest.fn() }; - -let out; -describe('Credit Banner view hooks', () => { - describe('state', () => { - state.testGetter(state.keys.creditRequestData); - }); - describe('useCreditRequestData', () => { - beforeEach(() => { - state.mock(); - out = hooks.useCreditRequestData(cardId); - }); - describe('behavior', () => { - it('initializes creditRequestData state field with null value', () => { - state.expectInitializedWith(state.keys.creditRequestData, null); - }); - it('calls useCreateCreditRequest with passed cardID', () => { - expect(apiHooks.useCreateCreditRequest).toHaveBeenCalledWith(cardId); - }); - }); - describe('output', () => { - it('returns requestData state value', () => { - state.mockVal(state.keys.creditRequestData, requestData); - out = hooks.useCreditRequestData(cardId); - expect(out.requestData).toEqual(requestData); - }); - describe('createCreditRequest', () => { - it('returns an event handler that prevents default click behavior', () => { - out.createCreditRequest(event); - expect(event.preventDefault).toHaveBeenCalled(); - }); - it('calls api.createCreditRequest and sets requestData with the response', async () => { - await out.createCreditRequest(event); - expect(creditRequest).toHaveBeenCalledWith(); - expect(state.setState.creditRequestData).toHaveBeenCalledWith(requestData.data); - }); - }); - }); - }); -}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.tsx b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.tsx new file mode 100644 index 000000000..f721432db --- /dev/null +++ b/src/containers/CourseCard/components/CourseCardBanners/CreditBanner/views/hooks.test.tsx @@ -0,0 +1,189 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import * as api from '@src/data/services/lms/api'; +import { useCourseData } from '@src/hooks'; +import { useAuthenticatedUser } from '@openedx/frontend-base'; +import * as hooks from './hooks'; + +jest.mock('@src/data/services/lms/api', () => ({ + createCreditRequest: jest.fn(), +})); + +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn(), +})); + +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + logError: jest.fn(), + useAuthenticatedUser: jest.fn(), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + return wrapper; +}; + +describe('useCreditRequestData', () => { + let wrapper; + + beforeEach(() => { + wrapper = createWrapper(); + (useAuthenticatedUser as jest.Mock).mockReturnValue({ username: 'test-user' }); + (useCourseData as jest.Mock).mockReturnValue({ + credit: { providerId: 'provider-123' }, + courseRun: { courseId: 'course-456' }, + }); + jest.clearAllMocks(); + }); + + it('initializes requestData as null', () => { + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + expect(result.current.requestData).toBeNull(); + }); + + it('returns createCreditRequest function', () => { + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + expect(typeof result.current.createCreditRequest).toBe('function'); + }); + + it('prevents default event behavior', async () => { + const event = { preventDefault: jest.fn() }; + (api.createCreditRequest as jest.Mock).mockResolvedValue({ data: 'success' }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('calls API with correct parameters', async () => { + const event = { preventDefault: jest.fn() }; + (api.createCreditRequest as jest.Mock).mockResolvedValue({ data: 'success' }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(api.createCreditRequest).toHaveBeenCalledWith({ + providerId: 'provider-123', + courseId: 'course-456', + username: 'test-user', + }); + }); + + it('sets requestData with response data on success', async () => { + const event = { preventDefault: jest.fn() }; + const responseData = { data: { id: 'credit-123', status: 'pending' } }; + (api.createCreditRequest as jest.Mock).mockResolvedValue(responseData); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(api.createCreditRequest).toHaveBeenCalledWith({ + providerId: 'provider-123', + courseId: 'course-456', + username: 'test-user', + }); + + await waitFor(() => { + expect(result.current.requestData).toEqual(responseData.data); + }); + }); + + it('handles missing providerId gracefully', async () => { + const event = { preventDefault: jest.fn() }; + (useCourseData as jest.Mock).mockReturnValue({ + credit: null, + courseRun: { courseId: 'course-456' }, + }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(api.createCreditRequest).toHaveBeenCalledWith({ + providerId: undefined, + courseId: 'course-456', + username: 'test-user', + }); + }); + + it('handles missing courseId gracefully', async () => { + const event = { preventDefault: jest.fn() }; + (useCourseData as jest.Mock).mockReturnValue({ + credit: { providerId: 'provider-123' }, + courseRun: null, + }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(api.createCreditRequest).toHaveBeenCalledWith({ + providerId: 'provider-123', + courseId: undefined, + username: 'test-user', + }); + }); + + it('handles API errors without crashing', async () => { + const event = { preventDefault: jest.fn() }; + (api.createCreditRequest as jest.Mock).mockRejectedValue(new Error('API Error')); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + expect(result.current.requestData).toBeNull(); + }); + + it('uses cardId to fetch course data', () => { + renderHook(() => hooks.useCreditRequestData('different-card'), { wrapper }); + + expect(useCourseData).toHaveBeenCalledWith('different-card'); + }); + + it('handles undefined response data', async () => { + const event = { preventDefault: jest.fn() }; + (api.createCreditRequest as jest.Mock).mockResolvedValue({ status: 200 }); + + const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper }); + + await act(async () => { + result.current.createCreditRequest(event); + }); + + await waitFor(() => { + expect(result.current.requestData).toBeUndefined(); + }); + }); +}); diff --git a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx index d51d28598..47204692f 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.jsx @@ -1,16 +1,21 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; import { Button, MailtoLink } from '@openedx/paragon'; -import { utilHooks, reduxHooks } from '../../../../hooks'; -import Banner from '../../../../components/Banner'; +import { utilHooks, useCourseData, useEntitlementInfo } from '@src/hooks'; +import { useSelectSessionModal } from '@src/data/context'; +import Banner from '@src/components/Banner'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import messages from './messages'; export const EntitlementBanner = ({ cardId }) => { const { formatMessage } = useIntl(); + const { data: learnerHomeData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); + const { isEntitlement, hasSessions, @@ -18,9 +23,12 @@ export const EntitlementBanner = ({ cardId }) => { changeDeadline, showExpirationWarning, isExpired, - } = reduxHooks.useCardEntitlementData(cardId); - const { supportEmail } = reduxHooks.usePlatformSettingsData(); - const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); + } = useEntitlementInfo(courseData); + const supportEmail = useMemo( + () => learnerHomeData?.platformSettings?.supportEmail, + [learnerHomeData], + ); + const { updateSelectSessionModal } = useSelectSessionModal(); const formatDate = utilHooks.useFormatDate(); if (!isEntitlement) { @@ -42,7 +50,7 @@ export const EntitlementBanner = ({ cardId }) => { {formatMessage(messages.entitlementExpiringSoon, { changeDeadline: formatDate(changeDeadline), selectSessionButton: ( - ), diff --git a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.test.jsx index 7c96738fa..4f315dc64 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/EntitlementBanner.test.jsx @@ -1,22 +1,40 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@openedx/frontend-base'; import { formatMessage } from '@src/testUtils'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import EntitlementBanner from './EntitlementBanner'; import messages from './messages'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn().mockReturnValue({ + data: { + platformSettings: { + supportEmail: 'test-support-email', + }, + }, + }), +})); +const mockUpdateSelectSessionModal = jest.fn().mockName('updateSelectSessionModal'); +jest.mock('@src/data/context/SelectSessionProvider', () => ({ + useSelectSessionModal: () => ({ + updateSelectSessionModal: mockUpdateSelectSessionModal, + }), +})); + jest.mock('@src/hooks', () => ({ + ...jest.requireActual('@src/hooks'), + useCourseData: jest.fn(), utilHooks: { - useFormatDate: () => date => date, - }, - reduxHooks: { - usePlatformSettingsData: jest.fn(), - useCardEntitlementData: jest.fn(), - useUpdateSelectSessionModalCallback: jest.fn( - (cardId) => jest.fn().mockName(`updateSelectSessionModalCallback(${cardId})`), - ), + useFormatDate: () => date => date?.toDateString(), }, + })); const cardId = 'test-card-id'; @@ -32,16 +50,20 @@ const platformData = { supportEmail: 'test-support-email' }; const renderComponent = (overrides = {}) => { const { entitlement = {} } = overrides; - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ ...entitlementData, ...entitlement }); - reduxHooks.usePlatformSettingsData.mockReturnValueOnce(platformData); + useCourseData.mockReturnValue({ + entitlement: { ...entitlementData, ...entitlement }, + platformSettings: platformData, + }); return render(); }; describe('EntitlementBanner', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('initializes data with course number from entitlement', () => { renderComponent(); - expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); it('no display if not an entitlement', () => { renderComponent({ entitlement: { isEntitlement: false } }); @@ -56,7 +78,10 @@ describe('EntitlementBanner', () => { expect(banner.innerHTML).toContain(platformData.supportEmail); }); it('renders when expiration warning', () => { - renderComponent({ entitlement: { showExpirationWarning: true } }); + const deadline = new Date(); + deadline.setDate(deadline.getDate() + 4); + const deadlineStr = `${deadline.getMonth() + 1}/${deadline.getDate()}/${deadline.getFullYear()}`; + renderComponent({ entitlement: { changeDeadline: deadlineStr, isFulfilled: false, availableSessions: [1, 2, 3] } }); const banner = screen.getByRole('alert'); expect(banner).toBeInTheDocument(); expect(banner).toHaveClass('alert-info'); @@ -64,9 +89,37 @@ describe('EntitlementBanner', () => { expect(button).toBeInTheDocument(); }); it('renders expired banner', () => { - renderComponent({ entitlement: { isExpired: true } }); + renderComponent({ entitlement: { isExpired: true, availableSessions: [1, 2, 3] } }); const banner = screen.getByRole('alert'); expect(banner).toBeInTheDocument(); expect(banner.innerHTML).toContain(formatMessage(messages.entitlementExpired)); }); + it('should call updateSelectSessionModal with cardId when select session button is clicked', async () => { + const user = userEvent.setup(); + const deadline = new Date(); + deadline.setDate(deadline.getDate() + 4); + const deadlineStr = `${deadline.getMonth() + 1}/${deadline.getDate()}/${deadline.getFullYear()}`; + renderComponent({ entitlement: { changeDeadline: deadlineStr, isFulfilled: false, availableSessions: [1, 2, 3] } }); + const banner = screen.getByRole('alert'); + expect(banner).toBeInTheDocument(); + expect(banner).toHaveClass('alert-info'); + const button = screen.getByRole('button', { name: formatMessage(messages.selectSession) }); + expect(button).toBeInTheDocument(); + await user.click(button); + + expect(mockUpdateSelectSessionModal).toHaveBeenCalledWith(cardId); + }); + it('should return null when isExpired is false and showExpirationWarning is false', () => { + renderComponent({ + entitlement: { + isEntitlement: true, + hasSessions: true, + isFulfilled: true, + showExpirationWarning: false, + isExpired: false, + }, + }); + const banner = screen.queryByRole('alert'); + expect(banner).toBeNull(); + }); }); diff --git a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx index ee213f0c2..63d683eca 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.jsx @@ -4,18 +4,18 @@ import PropTypes from 'prop-types'; import { Program } from '@openedx/paragon/icons'; import { useIntl } from '@openedx/frontend-base'; -import { reduxHooks } from '../../../../../hooks'; -import Banner from '../../../../../components/Banner'; +import { useCourseData } from '@src/hooks'; +import Banner from '@src/components/Banner'; import ProgramList from './ProgramsList'; import messages from './messages'; export const RelatedProgramsBanner = ({ cardId }) => { const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); + const programData = courseData?.programs; - const programData = reduxHooks.useCardRelatedProgramsData(cardId); - - if (!programData?.length) { + if (!courseData || !programData?.relatedPrograms.length) { return null; } @@ -27,7 +27,7 @@ export const RelatedProgramsBanner = ({ cardId }) => { {formatMessage(messages.relatedPrograms)} - + ); }; diff --git a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.test.jsx index 298a1f193..84ac74cf8 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/RelatedProgramsBanner/index.test.jsx @@ -1,13 +1,11 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import RelatedProgramsBanner from '.'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardRelatedProgramsData: jest.fn(), - }, + useCourseData: jest.fn(), })); const cardId = 'test-card-id'; @@ -27,21 +25,21 @@ const programData = { describe('RelatedProgramsBanner', () => { it('render empty', () => { - reduxHooks.useCardRelatedProgramsData.mockReturnValue({}); + useCourseData.mockReturnValue(null); render(); const banner = screen.queryByRole('alert'); expect(banner).toBeNull(); }); it('render with programs', () => { - reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData); + useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } }); render(); const list = screen.getByRole('list'); expect(list.childElementCount).toBe(programData.list.length); }); it('render related programs title', () => { - reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData); + useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } }); render(); const title = screen.getByText('Related Programs:'); expect(title).toBeInTheDocument(); diff --git a/src/containers/CourseCard/components/CourseCardBanners/index.jsx b/src/containers/CourseCard/components/CourseCardBanners/index.jsx index 77eff4592..0465b464f 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/index.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/index.jsx @@ -1,16 +1,20 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { reduxHooks } from '../../../../hooks'; -import CourseBannerSlot from '../../../../slots/CourseBannerSlot'; +import { useCourseData } from '@src/hooks'; +import CourseBannerSlot from '@src/slots/CourseBannerSlot'; import CertificateBanner from './CertificateBanner'; import CreditBanner from './CreditBanner'; import EntitlementBanner from './EntitlementBanner'; import RelatedProgramsBanner from './RelatedProgramsBanner'; export const CourseCardBanners = ({ cardId }) => { - const { isEnrolled } = reduxHooks.useCardEnrollmentData(cardId); + const courseData = useCourseData(cardId); + if (!courseData) { + return null; + } + const { isEnrolled = false } = courseData.enrollment; return (
diff --git a/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx b/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx index e6097f1c6..2c259cb4c 100644 --- a/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardBanners/index.test.jsx @@ -1,8 +1,9 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; + +import { useCourseData } from '@src/hooks'; -import { reduxHooks } from '@src/hooks'; import CourseCardBanners from '.'; jest.mock('./CourseBanner', () => jest.fn(() =>
CourseBanner
)); @@ -20,9 +21,11 @@ const mockedComponents = [ ]; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardEnrollmentData: jest.fn(() => ({ isEnrolled: true })), - }, + useCourseData: jest.fn(() => ({ + enrollment: { + isEnrolled: true, + }, + })), })); describe('CourseCardBanners', () => { @@ -30,28 +33,20 @@ describe('CourseCardBanners', () => { cardId: 'test-card-id', }; it('renders default CourseCardBanners', () => { - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: true }); - render( - - - - - - ); + render(); mockedComponents.map((componentName) => { const mockedComponent = screen.getByText(componentName); return expect(mockedComponent).toBeInTheDocument(); }); }); + it('render null with no courseData', () => { + useCourseData.mockReturnValue(null); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); it('render with isEnrolled false', () => { - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false }); - render( - - - - - - ); + useCourseData.mockReturnValue({ enrollment: { isEnrolled: false } }); + render(); const mockedComponentsIfNotEnrolled = mockedComponents.slice(-2); mockedComponentsIfNotEnrolled.map((componentName) => { const mockedComponent = screen.getByText(componentName); diff --git a/src/containers/CourseCard/components/CourseCardDetails/hooks.js b/src/containers/CourseCard/components/CourseCardDetails/hooks.js index 2ff2a6778..35821d404 100644 --- a/src/containers/CourseCard/components/CourseCardDetails/hooks.js +++ b/src/containers/CourseCard/components/CourseCardDetails/hooks.js @@ -1,22 +1,23 @@ import { useIntl } from '@openedx/frontend-base'; -import { utilHooks, reduxHooks } from '../../../../hooks'; +import { utilHooks, useCourseData, useEntitlementInfo } from '@src/hooks'; +import { useSelectSessionModal } from '@src/data/context'; import * as hooks from './hooks'; import messages from './messages'; export const useAccessMessage = ({ cardId }) => { const { formatMessage } = useIntl(); - const enrollment = reduxHooks.useCardEnrollmentData(cardId); - const courseRun = reduxHooks.useCardCourseRunData(cardId); + const courseData = useCourseData(cardId); + const { courseRun, enrollment } = courseData || {}; const formatDate = utilHooks.useFormatDate(); if (!courseRun.isStarted) { if (!courseRun.startDate && !courseRun.advertisedStart) { return null; } - const startDate = courseRun.advertisedStart ?? formatDate(courseRun.startDate); + const startDate = courseRun.advertisedStart || formatDate(courseRun.startDate); return formatMessage(messages.courseStarts, { startDate }); } - if (enrollment.isEnrolled) { + if (enrollment?.isEnrolled) { const { isArchived, endDate } = courseRun; const { accessExpirationDate, @@ -42,23 +43,23 @@ export const useAccessMessage = ({ cardId }) => { export const useCardDetailsData = ({ cardId }) => { const { formatMessage } = useIntl(); - const providerName = reduxHooks.useCardProviderData(cardId).name; - const { courseNumber } = reduxHooks.useCardCourseData(cardId); + const courseData = useCourseData(cardId); + const providerName = courseData?.courseProvider?.name; + const courseNumber = courseData?.course?.courseNumber; const { isEntitlement, isFulfilled, canChange, - } = reduxHooks.useCardEntitlementData(cardId); - - const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); + } = useEntitlementInfo(courseData); + const { updateSelectSessionModal } = useSelectSessionModal(); return { - providerName: providerName ?? formatMessage(messages.unknownProviderName), + providerName: providerName || formatMessage(messages.unknownProviderName), accessMessage: hooks.useAccessMessage({ cardId }), isEntitlement, isFulfilled, canChange, - openSessionModal, + openSessionModal: () => updateSelectSessionModal(cardId), courseNumber, changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton), }; diff --git a/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js b/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js index 13b193192..86c4969ad 100644 --- a/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardDetails/hooks.test.js @@ -1,22 +1,26 @@ import { useIntl } from '@openedx/frontend-base'; import { keyStore } from '@src/utils'; -import { utilHooks, reduxHooks } from '@src/hooks'; +import { utilHooks, useCourseData } from '@src/hooks'; +import { useSelectSessionModal } from '@src/data/context'; import * as hooks from './hooks'; import messages from './messages'; +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: (fn) => fn(), +})); + +const updateSelectSessionModalMock = jest.fn().mockName('updateSelectSessionModal'); +jest.mock('@src/data/context', () => ({ + useSelectSessionModal: jest.fn(), +})); jest.mock('@src/hooks', () => ({ + ...jest.requireActual('@src/hooks'), + useCourseData: jest.fn(), utilHooks: { useFormatDate: jest.fn(), }, - reduxHooks: { - useCardCourseData: jest.fn(), - useCardCourseRunData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardEntitlementData: jest.fn(), - useCardProviderData: jest.fn(), - useUpdateSelectSessionModalCallback: (...args) => ({ updateSelectSessionModalCallback: args }), - }, })); jest.mock('@openedx/frontend-base', () => { @@ -45,8 +49,9 @@ describe('CourseCardDetails hooks', () => { }); describe('useCardDetailsData', () => { - const providerName = 'my-provider-name'; - const providerData = {}; + const providerData = { + name: 'my-provider-name', + }; const entitlementData = { isEntitlement: false, disableViewCourse: false, @@ -58,15 +63,13 @@ describe('CourseCardDetails hooks', () => { const runHook = ({ provider = {}, entitlement = {} }) => { jest.spyOn(hooks, hookKeys.useAccessMessage) .mockImplementationOnce(mockAccessMessage); - reduxHooks.useCardProviderData.mockReturnValueOnce({ - ...providerData, - ...provider, + useCourseData.mockReturnValue({ + courseProvider: { ...providerData, ...provider }, + course: { courseNumber }, + courseRun: {}, + entitlement: { ...entitlementData, ...entitlement }, }); - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ - ...entitlementData, - ...entitlement, - }); - reduxHooks.useCardCourseData.mockReturnValueOnce({ courseNumber }); + useSelectSessionModal.mockReturnValue({ updateSelectSessionModal: updateSelectSessionModalMock }); out = hooks.useCardDetailsData({ cardId }); }; beforeEach(() => { @@ -76,15 +79,17 @@ describe('CourseCardDetails hooks', () => { expect(out.accessMessage).toEqual(mockAccessMessage({ cardId })); }); it('forwards provider name if it exists, else formatted unknown provider name', () => { - runHook({ provider: { name: providerName } }); - expect(out.providerName).toEqual(providerName); - - runHook({ provider: {} }); + expect(out.providerName).toEqual(providerData.name); + runHook({ provider: { name: '' } }); expect(out.providerName).toEqual(formatMessage(messages.unknownProviderName)); }); it('forward changeOrLeaveSessionMessage', () => { expect(out.changeOrLeaveSessionMessage).toEqual(formatMessage(messages.changeOrLeaveSessionButton)); }); + it('calls updateSelectSessionModal when openSessionModal is called', () => { + out.openSessionModal(); + expect(updateSelectSessionModalMock).toHaveBeenCalledWith(cardId); + }); }); describe('useAccessMessage', () => { @@ -101,21 +106,16 @@ describe('CourseCardDetails hooks', () => { endDate: '10/20/2000', }; const runHook = ({ enrollment = {}, courseRun = {} }) => { - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ - ...courseRunData, - ...courseRun, - }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - ...enrollmentData, - ...enrollment, + useCourseData.mockReturnValue({ + courseRun: { ...courseRunData, ...courseRun }, + enrollment: { ...enrollmentData, ...enrollment }, }); out = hooks.useAccessMessage({ cardId }); }; it('loads data from enrollment and course run data based on course number', () => { runHook({}); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId); - expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId); + expect(useCourseData).toHaveBeenCalledWith(cardId); }); describe('if not started yet', () => { diff --git a/src/containers/CourseCard/components/CourseCardImage.jsx b/src/containers/CourseCard/components/CourseCardImage.jsx index 93a085f7e..8c2bce6fd 100644 --- a/src/containers/CourseCard/components/CourseCardImage.jsx +++ b/src/containers/CourseCard/components/CourseCardImage.jsx @@ -1,12 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; +import { baseAppUrl } from '@src/data/services/lms/urls'; import { Badge } from '@openedx/paragon'; -import track from '../../../tracking'; -import { reduxHooks } from '../../../hooks'; -import verifiedRibbon from '../../../assets/verified-ribbon.png'; +import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; +import verifiedRibbon from '@src/assets/verified-ribbon.png'; import useActionDisabledState from './hooks'; import messages from '../messages'; @@ -15,11 +16,10 @@ const { courseImageClicked } = track.course; export const CourseCardImage = ({ cardId, orientation }) => { const { formatMessage } = useIntl(); - const { bannerImgSrc } = reduxHooks.useCardCourseData(cardId); - const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); - const { isVerified } = reduxHooks.useCardEnrollmentData(cardId); + const courseData = useCourseData(cardId); + const { homeUrl } = courseData?.courseRun || {}; const { disableCourseTitle } = useActionDisabledState(cardId); - const handleImageClicked = reduxHooks.useTrackCourseEvent(courseImageClicked, cardId, homeUrl); + const handleImageClicked = useCourseTrackingEvent(courseImageClicked, cardId, homeUrl); const wrapperClassName = `pgn__card-wrapper-image-cap d-inline-block overflow-visible ${orientation}`; const image = ( <> @@ -27,11 +27,11 @@ export const CourseCardImage = ({ cardId, orientation }) => { // w-100 is necessary for images on Safari, otherwise stretches full height of the image // https://stackoverflow.com/a/44250830 className="pgn__card-image-cap w-100 show" - src={bannerImgSrc} + src={courseData?.course?.bannerImgSrc && baseAppUrl(courseData.course.bannerImgSrc)} alt={formatMessage(messages.bannerAlt)} /> { - isVerified && ( + courseData?.enrollment?.isVerified && ( ({ - reduxHooks: { - useCardCourseData: jest.fn(() => ({ bannerImgSrc })), - useCardCourseRunData: jest.fn(() => ({ homeUrl })), - useCardEnrollmentData: jest.fn(), - useTrackCourseEvent: jest.fn((eventName, cardId, url) => ({ - trackCourseEvent: { eventName, cardId, url }, - })), - }, + useCourseData: jest.fn(() => ({ + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: {}, + })), + useCourseTrackingEvent: jest.fn((eventName, cardId, url) => ({ + trackCourseEvent: { eventName, cardId, url }, + })), })); jest.mock('./hooks', () => jest.fn()); @@ -30,7 +30,13 @@ describe('CourseCardImage', () => { it('renders course image with correct attributes', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: true }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); + useCourseData.mockReturnValue( + { + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: { isVerified: true }, + }, + ); render(); const image = screen.getByRole('img', { name: formatMessage(messages.bannerAlt) }); @@ -41,7 +47,13 @@ describe('CourseCardImage', () => { it('isVerified, should render badge', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); + useCourseData.mockReturnValue( + { + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: { isVerified: true }, + }, + ); render(); const badge = screen.getByText(formatMessage(messages.verifiedBanner)); @@ -52,7 +64,13 @@ describe('CourseCardImage', () => { it('renders link with correct href if disableCourseTitle is false', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: false }); + useCourseData.mockReturnValue( + { + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: { isVerified: false }, + }, + ); render(); const link = screen.getByRole('link'); @@ -61,12 +79,15 @@ describe('CourseCardImage', () => { describe('hooks', () => { it('initializes', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true }); - render(); - expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith( - props.cardId, + useCourseData.mockReturnValue( + { + course: { bannerImgSrc }, + courseRun: { homeUrl }, + enrollment: { isVerified: true }, + }, ); + render(); + expect(useCourseData).toHaveBeenCalledWith(props.cardId); expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId); }); }); diff --git a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx index 5a18029d6..65eb81143 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.jsx @@ -1,14 +1,12 @@ -import { useContext } from 'react'; import PropTypes from 'prop-types'; import * as ReactShare from 'react-share'; - +import { EXECUTIVE_EDUCATION_COURSE_MODES } from '@src/data/constants/course'; import { useIntl } from '@openedx/frontend-base'; import { Dropdown } from '@openedx/paragon'; -import MasqueradeUserContext from '../../../../data/contexts/MasqueradeUserContext'; -import track from '../../../../tracking'; -import { reduxHooks } from '../../../../hooks'; - +import track from '@src/tracking'; +import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from '@src/hooks'; +import { useCardSocialSettingsData } from './hooks'; import messages from './messages'; export const testIds = { @@ -17,14 +15,15 @@ export const testIds = { export const SocialShareMenu = ({ cardId, emailSettings }) => { const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); + const courseName = courseData?.course?.courseName; + const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode); + const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false; + const { twitter, facebook } = useCardSocialSettingsData(cardId); + const isMasquerading = useIsMasquerading(); - const { courseName } = reduxHooks.useCardCourseData(cardId); - const { isEmailEnabled, isExecEd2UCourse } = reduxHooks.useCardEnrollmentData(cardId); - const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId); - const { isMasquerading } = useContext(MasqueradeUserContext); - - const handleTwitterShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'twitter'); - const handleFacebookShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'facebook'); + const handleTwitterShare = useCourseTrackingEvent(track.socialShare, cardId, 'twitter'); + const handleFacebookShare = useCourseTrackingEvent(track.socialShare, cardId, 'facebook'); if (isExecEd2UCourse) { return null; @@ -51,6 +50,7 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => { })} resetButtonStyle={false} className="pgn__dropdown-item dropdown-item" + aria-label="facebook" > {formatMessage(messages.shareToFacebook)} @@ -65,6 +65,7 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => { })} resetButtonStyle={false} className="pgn__dropdown-item dropdown-item" + aria-label="twitter" > {formatMessage(messages.shareToTwitter)} diff --git a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx index 4a6e6fa63..c1c2b8eb0 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/SocialShareMenu.test.jsx @@ -1,11 +1,12 @@ +import { when } from 'jest-when'; + import { IntlProvider } from '@openedx/frontend-base'; import { render, screen } from '@testing-library/react'; -import { when } from 'jest-when'; + import track from '@src/tracking'; -import { reduxHooks } from '@src/hooks'; -import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext'; +import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from '@src/hooks'; -import { useEmailSettings } from './hooks'; +import { useEmailSettings, useCardSocialSettingsData } from './hooks'; import SocialShareMenu from './SocialShareMenu'; import messages from './messages'; @@ -14,15 +15,13 @@ jest.mock('@src/tracking', () => ({ })); jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardSocialSettingsData: jest.fn(), - useTrackCourseEvent: jest.fn((...args) => ({ trackCourseEvent: args })), - }, + useCourseData: jest.fn(), + useCourseTrackingEvent: jest.fn((...args) => ({ trackCourseEvent: args })), + useIsMasquerading: jest.fn(), })); jest.mock('./hooks', () => ({ useEmailSettings: jest.fn(), + useCardSocialSettingsData: jest.fn(), })); const props = { @@ -55,31 +54,28 @@ const socialShare = { const mockHooks = (returnVals = {}) => { mockHook( - reduxHooks.useCardEnrollmentData, + useCourseData, { - isEmailEnabled: !!returnVals.isEmailEnabled, - isExecEd2UCourse: !!returnVals.isExecEd2UCourse, + enrollment: { + isEmailEnabled: !!returnVals.isEmailEnabled, + mode: returnVals.isExecEd2UCourse ? 'exec-ed-2u' : 'standard', + }, + course: { courseName }, }, { isCardHook: true }, ); - mockHook(reduxHooks.useCardCourseData, { courseName }, { isCardHook: true }); mockHook( - reduxHooks.useCardSocialSettingsData, + useCardSocialSettingsData, { facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled }, twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled }, }, { isCardHook: true }, ); + mockHook(useIsMasquerading, !!returnVals.isMasquerading); }; -const renderComponent = (isMasquerading = false) => render( - - - - - , -); +const renderComponent = () => render(); describe('SocialShareMenu', () => { describe('behavior', () => { @@ -90,12 +86,12 @@ describe('SocialShareMenu', () => { it('initializes local hooks', () => { when(useEmailSettings).expectCalledWith(); }); - it('initializes redux hook data ', () => { - when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId); - when(reduxHooks.useCardCourseData).expectCalledWith(props.cardId); - when(reduxHooks.useCardSocialSettingsData).expectCalledWith(props.cardId); - when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter'); - when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook'); + it('initializes hook data ', () => { + when(useCourseData).expectCalledWith(props.cardId); + when(useCardSocialSettingsData).expectCalledWith(props.cardId); + when(useIsMasquerading).expectCalledWith(); + when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter'); + when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook'); }); }); describe('render', () => { @@ -118,7 +114,6 @@ describe('SocialShareMenu', () => { if (isMasquerading) { it('is disabled', () => { const emailSettingsButton = screen.getByRole('button', { name: messages.emailSettings.defaultMessage }); - expect(emailSettingsButton).toBeInTheDocument(); expect(emailSettingsButton).toHaveAttribute('aria-disabled', 'true'); expect(emailSettingsButton).toHaveClass('disabled'); }); @@ -171,8 +166,8 @@ describe('SocialShareMenu', () => { }); describe('masquerading', () => { beforeEach(() => { - mockHooks({ isEmailEnabled: true }); - renderComponent(true); + mockHooks({ isEmailEnabled: true, isMasquerading: true }); + renderComponent(); }); testEmailSettingsDropdown(true); }); diff --git a/src/containers/CourseCard/components/CourseCardMenu/hooks.js b/src/containers/CourseCard/components/CourseCardMenu/hooks.js index 4b1ed524b..6c0f92447 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/hooks.js +++ b/src/containers/CourseCard/components/CourseCardMenu/hooks.js @@ -1,8 +1,8 @@ +import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; import { useState } from 'react'; - -import { reduxHooks } from '../../../../hooks'; -import track from '../../../../tracking'; -import { StrictDict } from '../../../../utils'; +import { StrictDict } from '@src/utils'; +import { useInitializeLearnerHome } from '@src/data/hooks'; export const state = StrictDict({ isUnenrollConfirmVisible: (val) => useState(val), // eslint-disable-line @@ -28,7 +28,7 @@ export const useEmailSettings = () => { }; export const useHandleToggleDropdown = (cardId) => { - const trackCourseEvent = reduxHooks.useTrackCourseEvent( + const trackCourseEvent = useCourseTrackingEvent( track.course.courseOptionsDropdownClicked, cardId, ); @@ -39,10 +39,30 @@ export const useHandleToggleDropdown = (cardId) => { }; }; +export const useCardSocialSettingsData = (cardId) => { + const { data: learnerHomeData } = useInitializeLearnerHome(); + const courseData = useCourseData(cardId); + const socialShareSettings = learnerHomeData?.socialShareSettings; + const { socialShareUrl } = courseData?.course || {}; + const defaultSettings = { isEnabled: false, shareUrl: '' }; + + if (!socialShareSettings) { + return { facebook: defaultSettings, twitter: defaultSettings }; + } + const { facebook, twitter } = socialShareSettings; + const loadSettings = (target) => ({ + isEnabled: target.isEnabled, + shareUrl: `${socialShareUrl}?${target.utmParams}`, + }); + return { facebook: loadSettings(facebook), twitter: loadSettings(twitter) }; +}; + export const useOptionVisibility = (cardId) => { - const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId); - const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId); - const { isEarned } = reduxHooks.useCardCertificateData(cardId); + const courseData = useCourseData(cardId); + const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false; + const isEnrolled = courseData?.enrollment?.isEnrolled ?? false; + const { twitter, facebook } = useCardSocialSettingsData(cardId); + const isEarned = courseData?.certificate?.isEarned ?? false; const shouldShowUnenrollItem = isEnrolled && !isEarned; const shouldShowDropdown = ( diff --git a/src/containers/CourseCard/components/CourseCardMenu/hooks.test.js b/src/containers/CourseCard/components/CourseCardMenu/hooks.test.js index 5581b98da..c8145f4cd 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/hooks.test.js +++ b/src/containers/CourseCard/components/CourseCardMenu/hooks.test.js @@ -1,20 +1,21 @@ -import { reduxHooks } from '@src/hooks'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import track from '@src/tracking'; import { MockUseState } from '@src/testUtils'; import * as hooks from './hooks'; +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), +})); + jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCertificateData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardSocialSettingsData: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, + useCourseData: jest.fn(), + useCourseTrackingEvent: jest.fn(), })); const trackCourseEvent = jest.fn(); -reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent); +useCourseTrackingEvent.mockReturnValue(trackCourseEvent); const cardId = 'test-card-id'; let out; @@ -73,7 +74,7 @@ describe('CourseCardMenu hooks', () => { }); describe('behavior', () => { it('initializes course event tracker with event name and card ID', () => { - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.courseOptionsDropdownClicked, cardId, ); @@ -90,55 +91,61 @@ describe('CourseCardMenu hooks', () => { }); describe('useOptionVisibility', () => { - const mockReduxHooks = (returnVals = {}) => { - reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({ - facebook: { isEnabled: !!returnVals.facebook?.isEnabled }, - twitter: { isEnabled: !!returnVals.twitter?.isEnabled }, - }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - isEnrolled: !!returnVals.isEnrolled, - isEmailEnabled: !!returnVals.isEmailEnabled, - }); - reduxHooks.useCardCertificateData.mockReturnValueOnce({ - isEarned: !!returnVals.isEarned, + const mockHooks = (returnVals = {}) => { + useInitializeLearnerHome.mockReturnValue({ + data: { + socialShareSettings: { + facebook: { isEnabled: !!returnVals.facebook?.isEnabled }, + twitter: { isEnabled: !!returnVals.twitter?.isEnabled }, + }, + }, + }); + useCourseData.mockReturnValue({ + enrollment: { + isEnrolled: !!returnVals.isEnrolled, + isEmailEnabled: !!returnVals.isEmailEnabled, + }, + certificate: { + isEarned: !!returnVals.isEarned, + }, }); }; describe('shouldShowUnenrollItem', () => { it('returns true if enrolled and not earned', () => { - mockReduxHooks({ isEnrolled: true }); + mockHooks({ isEnrolled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true); }); it('returns false if not enrolled', () => { - mockReduxHooks(); + mockHooks(); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false); }); it('returns false if enrolled but also earned', () => { - mockReduxHooks({ isEarned: true }); + mockHooks({ isEarned: true }); expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false); }); }); describe('shouldShowDropdown', () => { it('returns false if not enrolled and both email and socials are disabled', () => { - mockReduxHooks(); + mockHooks(); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false); }); it('returns false if enrolled but already earned, and both email and socials are disabled', () => { - mockReduxHooks({ isEnrolled: true, isEarned: true }); + mockHooks({ isEnrolled: true, isEarned: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false); }); it('returns true if either social is enabled', () => { - mockReduxHooks({ facebook: { isEnabled: true } }); + mockHooks({ facebook: { isEnabled: true } }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); - mockReduxHooks({ twitter: { isEnabled: true } }); + mockHooks({ twitter: { isEnabled: true } }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); it('returns true if email is enabled', () => { - mockReduxHooks({ isEmailEnabled: true }); + mockHooks({ isEmailEnabled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); it('returns true if enrolled and not earned', () => { - mockReduxHooks({ isEnrolled: true }); + mockHooks({ isEnrolled: true }); expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true); }); }); diff --git a/src/containers/CourseCard/components/CourseCardMenu/index.jsx b/src/containers/CourseCard/components/CourseCardMenu/index.jsx index d2d7d1992..29d43f42d 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/index.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/index.jsx @@ -1,15 +1,12 @@ -import { useContext } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; import { Dropdown, Icon, IconButton } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; -import MasqueradeUserContext from '../../../../data/contexts/MasqueradeUserContext'; -import EmailSettingsModal from '../../../../containers/EmailSettingsModal'; -import UnenrollConfirmModal from '../../../../containers/UnenrollConfirmModal'; -import { reduxHooks } from '../../../../hooks'; - +import EmailSettingsModal from '@src/containers/EmailSettingsModal'; +import UnenrollConfirmModal from '@src/containers/UnenrollConfirmModal'; +import { useCourseData, useIsMasquerading } from '@src/hooks'; import SocialShareMenu from './SocialShareMenu'; import { useEmailSettings, @@ -26,13 +23,15 @@ export const testIds = { export const CourseCardMenu = ({ cardId }) => { const { formatMessage } = useIntl(); + const courseData = useCourseData(cardId); + + const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false; const emailSettings = useEmailSettings(); const unenrollModal = useUnenrollData(); const handleToggleDropdown = useHandleToggleDropdown(cardId); const { shouldShowUnenrollItem, shouldShowDropdown } = useOptionVisibility(cardId); - const { isMasquerading } = useContext(MasqueradeUserContext); - const { isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId); + const isMasquerading = useIsMasquerading(); if (!shouldShowDropdown) { return null; diff --git a/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx b/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx index db3aed076..c0405da53 100644 --- a/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx +++ b/src/containers/CourseCard/components/CourseCardMenu/index.test.jsx @@ -1,17 +1,17 @@ import { when } from 'jest-when'; + import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@openedx/frontend-base'; -import { reduxHooks } from '@src/hooks'; -import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext'; + +import { useCourseData, useIsMasquerading } from '@src/hooks'; import * as hooks from './hooks'; import CourseCardMenu from '.'; import messages from './messages'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardEnrollmentData: jest.fn(), - }, + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); jest.mock('./SocialShareMenu', () => jest.fn(() =>
SocialShareMenu
)); jest.mock('@src/containers/EmailSettingsModal', () => jest.fn(() =>
EmailSettingsModal
)); @@ -67,20 +67,19 @@ const mockHooks = (returnVals = {}) => { }, { isCardHook: true }, ); + mockHook(useIsMasquerading, !!returnVals.isMasquerading); mockHook( - reduxHooks.useCardEnrollmentData, - { isEmailEnabled: !!returnVals.isEmailEnabled }, + useCourseData, + { + enrollment: { + isEmailEnabled: !!returnVals.isEmailEnabled, + }, + }, { isCardHook: true }, ); }; -const renderComponent = (isMasquerading = false) => render( - - - - - -); +const renderComponent = () => render(); describe('CourseCardMenu', () => { describe('hooks', () => { @@ -90,12 +89,10 @@ describe('CourseCardMenu', () => { }); it('initializes local hooks', () => { when(hooks.useEmailSettings).expectCalledWith(); - when(hooks.useUnenrollData).expectCalledWith(); - when(hooks.useHandleToggleDropdown).expectCalledWith(props.cardId); - when(hooks.useOptionVisibility).expectCalledWith(props.cardId); }); - it('initializes redux hook data ', () => { - when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId); + it('initializes hook data ', () => { + when(useIsMasquerading).expectCalledWith(); + when(useCourseData).expectCalledWith(props.cardId); }); }); describe('render', () => { @@ -155,13 +152,14 @@ describe('CourseCardMenu', () => { }); describe('masquerading', () => { it('renders but unenroll is disabled', async () => { - mockHooks({ ...hookProps }); - renderComponent(true); + mockHooks({ ...hookProps, isMasquerading: true }); + renderComponent(); const user = userEvent.setup(); const dropdown = screen.getByRole('button', { name: messages.dropdownAlt.defaultMessage }); expect(dropdown).toBeInTheDocument(); await user.click(dropdown); + const unenrollOption = screen.getByRole('button', { name: messages.unenroll.defaultMessage }); expect(unenrollOption).toBeInTheDocument(); expect(unenrollOption).toHaveAttribute('aria-disabled', 'true'); diff --git a/src/containers/CourseCard/components/CourseCardTitle.jsx b/src/containers/CourseCard/components/CourseCardTitle.jsx index 39277f29a..475551062 100644 --- a/src/containers/CourseCard/components/CourseCardTitle.jsx +++ b/src/containers/CourseCard/components/CourseCardTitle.jsx @@ -1,16 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; -import track from '../../../tracking'; -import { reduxHooks } from '../../../hooks'; +import track from '@src/tracking'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; import useActionDisabledState from './hooks'; const { courseTitleClicked } = track.course; export const CourseCardTitle = ({ cardId }) => { - const { courseName } = reduxHooks.useCardCourseData(cardId); - const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); - const handleTitleClicked = reduxHooks.useTrackCourseEvent( + const courseData = useCourseData(cardId); + const courseName = courseData?.course?.courseName; + const homeUrl = courseData?.courseRun?.homeUrl; + const handleTitleClicked = useCourseTrackingEvent( courseTitleClicked, cardId, homeUrl, diff --git a/src/containers/CourseCard/components/CourseCardTitle.test.jsx b/src/containers/CourseCard/components/CourseCardTitle.test.jsx index a6c45ca2c..62086f5fa 100644 --- a/src/containers/CourseCard/components/CourseCardTitle.test.jsx +++ b/src/containers/CourseCard/components/CourseCardTitle.test.jsx @@ -1,9 +1,9 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData, useCourseTrackingEvent } from '@src/hooks'; +import track from '@src/tracking'; import useActionDisabledState from './hooks'; import CourseCardTitle from './CourseCardTitle'; -import track from '@src/tracking'; jest.mock('@src/tracking', () => ({ course: { @@ -12,11 +12,8 @@ jest.mock('@src/tracking', () => ({ })); jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseData: jest.fn(), - useCardCourseRunData: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, + useCourseData: jest.fn(), + useCourseTrackingEvent: jest.fn(), })); jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false }))); @@ -32,9 +29,11 @@ describe('CourseCardTitle', () => { beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useCardCourseData.mockReturnValue({ courseName }); - reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl }); - reduxHooks.useTrackCourseEvent.mockReturnValue(handleTitleClick); + useCourseData.mockReturnValue({ + course: { courseName }, + courseRun: { homeUrl }, + }); + useCourseTrackingEvent.mockReturnValue(handleTitleClick); }); it('renders course name as link when not disabled', async () => { @@ -62,9 +61,8 @@ describe('CourseCardTitle', () => { useActionDisabledState.mockReturnValue({ disableCourseTitle: false }); render(); - expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId); - expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( + expect(useCourseData).toHaveBeenCalledWith(props.cardId); + expect(useCourseTrackingEvent).toHaveBeenCalledWith( track.course.courseTitleClicked, props.cardId, homeUrl, diff --git a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx index d14831b70..9b988e359 100644 --- a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx +++ b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { useIntl } from '@openedx/frontend-base'; import { StrictDict } from '@src/utils'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import messages from './messages'; import * as module from './hooks'; @@ -14,7 +14,8 @@ export const state = StrictDict({ export const useRelatedProgramsBadgeData = ({ cardId }) => { const [isOpen, setIsOpen] = module.state.isOpen(false); const { formatMessage } = useIntl(); - const numPrograms = reduxHooks.useCardRelatedProgramsData(cardId).length; + const courseData = useCourseData(cardId); + const numPrograms = courseData?.programs?.relatedPrograms?.length || 0; let programsMessage = ''; if (numPrograms) { programsMessage = formatMessage( diff --git a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js index c9ee1445d..cd170b27b 100644 --- a/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js +++ b/src/containers/CourseCard/components/RelatedProgramsBadge/hooks.test.js @@ -1,16 +1,15 @@ import { useIntl } from '@openedx/frontend-base'; import { MockUseState } from '@src/testUtils'; -import { reduxHooks } from '@src/hooks'; +import { useCourseData } from '@src/hooks'; import * as hooks from './hooks'; import messages from './messages'; jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardRelatedProgramsData: jest.fn(), - }, + useCourseData: jest.fn(), })); + jest.mock('@openedx/frontend-base', () => { const { formatMessage } = jest.requireActual('@src/testUtils'); return { @@ -24,7 +23,7 @@ jest.mock('@openedx/frontend-base', () => { const cardId = 'test-card-id'; const state = new MockUseState(hooks); -let numPrograms = 27; +const numPrograms = 27; describe('RelatedProgramsBadge hooks', () => { const { formatMessage } = useIntl(); @@ -34,15 +33,14 @@ describe('RelatedProgramsBadge hooks', () => { }); beforeEach(() => { jest.clearAllMocks(); - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ - length: numPrograms, - }); }); describe('useRelatedProgramsBadgeData', () => { beforeEach(() => { state.mock(); - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ - length: numPrograms, + useCourseData.mockReturnValue({ + programs: { + relatedPrograms: new Array(numPrograms).fill({}), + }, }); out = hooks.useRelatedProgramsBadgeData({ cardId }); }); @@ -66,14 +64,12 @@ describe('RelatedProgramsBadge hooks', () => { expect(out.numPrograms).toEqual(numPrograms); }); test('returns empty programsMessage if no programs', () => { - reduxHooks.useCardRelatedProgramsData.mockReset(); - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 0 }); + useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [] } }); out = hooks.useRelatedProgramsBadgeData({ cardId }); expect(out.programsMessage).toEqual(''); }); test('returns badgeLabelSingular programsMessage if 1 programs', () => { - reduxHooks.useCardRelatedProgramsData.mockReset(); - reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 1 }); + useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [{}] } }); out = hooks.useRelatedProgramsBadgeData({ cardId }); expect(out.programsMessage).toEqual(formatMessage( messages.badgeLabelSingular, diff --git a/src/containers/CourseCard/components/hooks.js b/src/containers/CourseCard/components/hooks.js index 5fdacc7f6..d108b82cf 100644 --- a/src/containers/CourseCard/components/hooks.js +++ b/src/containers/CourseCard/components/hooks.js @@ -1,19 +1,19 @@ -import { useContext } from 'react'; - -import MasqueradeUserContext from '../../../data/contexts/MasqueradeUserContext'; -import { reduxHooks } from '../../../hooks'; +import { useCourseData, useEntitlementInfo, useIsMasquerading } from '@src/hooks'; export const useActionDisabledState = (cardId) => { - const { isMasquerading } = useContext(MasqueradeUserContext); + const courseData = useCourseData(cardId); + const isMasquerading = useIsMasquerading(); + const { - hasAccess, isAudit, isAuditAccessExpired, - } = reduxHooks.useCardEnrollmentData(cardId); + isAudit, isAuditAccessExpired, + } = courseData.enrollment || {}; + const { isStaff, hasUnmetPrereqs, isTooEarly } = courseData.enrollment?.coursewareAccess || {}; + const hasAccess = isStaff || !(hasUnmetPrereqs || isTooEarly); const { isEntitlement, isFulfilled, canChange, hasSessions, - } = reduxHooks.useCardEntitlementData(cardId); - - const { resumeUrl, homeUrl } = reduxHooks.useCardCourseRunData(cardId); + } = useEntitlementInfo(courseData); + const { resumeUrl, homeUrl } = courseData.courseRun || {}; const disableBeginCourse = !homeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired)); const disableResumeCourse = !resumeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired)); const disableViewCourse = !hasAccess || (isAudit && isAuditAccessExpired); diff --git a/src/containers/CourseCard/components/hooks.test.js b/src/containers/CourseCard/components/hooks.test.js index 5a27dd44f..7f02f172e 100644 --- a/src/containers/CourseCard/components/hooks.test.js +++ b/src/containers/CourseCard/components/hooks.test.js @@ -1,16 +1,15 @@ -import { reduxHooks } from '@src/hooks'; - +import { useCourseData, useIsMasquerading } from '@src/hooks'; import * as hooks from './hooks'; -import { renderHook } from '@testing-library/react'; -import MasqueradeUserContext from '@src/data/contexts/MasqueradeUserContext'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useMemo: jest.fn((fn) => fn()), +})); jest.mock('@src/hooks', () => ({ - reduxHooks: { - useMasqueradeData: jest.fn(), - useCardEnrollmentData: jest.fn(), - useCardEntitlementData: jest.fn(), - useCardCourseRunData: jest.fn(), - }, + ...jest.requireActual('@src/hooks'), + useCourseData: jest.fn(), + useIsMasquerading: jest.fn(), })); const cardId = 'my-test-course-number'; @@ -40,39 +39,43 @@ describe('useActionDisabledState', () => { isAuditAccessExpired, resumeUrl, homeUrl, + availableSessions, } = { ...defaultData, ...args }; - reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading }); - reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ - hasAccess, - isAudit, - isAuditAccessExpired, - }); - reduxHooks.useCardEntitlementData.mockReturnValueOnce({ - isEntitlement, - isFulfilled, - canChange, - hasSessions, - }); - reduxHooks.useCardCourseRunData.mockReturnValueOnce({ - resumeUrl, - homeUrl, + useIsMasquerading.mockReturnValue(isMasquerading); + useCourseData.mockReturnValue({ + enrollment: { + hasAccess, + isAudit, + isAuditAccessExpired, + coursewareAccess: { + isStaff: false, + hasUnmetPrereqs: !hasAccess, + isTooEarly: !hasAccess, + }, + }, + entitlement: isEntitlement ? { + isEntitlement: true, + isFulfilled, + canChange, + hasSessions, + availableSessions, + } : {}, + courseRun: { + resumeUrl, + homeUrl, + }, }); }; - const runHook = (masqueradeValue = { isMasquerading: false }) => { - const { result } = renderHook(() => hooks.useActionDisabledState(cardId), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - return result.current; - }; + beforeEach(() => { + jest.clearAllMocks(); + }); + + const runHook = () => hooks.useActionDisabledState(cardId); describe('disableBeginCourse', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableBeginCourse).toBe(expected); + expect(runHook().disableBeginCourse).toBe(expected); }; it('disable when homeUrl is invalid', () => { testDisabled({ homeUrl: null }, true); @@ -93,7 +96,7 @@ describe('useActionDisabledState', () => { describe('disableResumeCourse', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableResumeCourse).toBe(expected); + expect(runHook().disableResumeCourse).toBe(expected); }; it('disable when resumeUrl is invalid', () => { testDisabled({ resumeUrl: null }, true); @@ -114,7 +117,7 @@ describe('useActionDisabledState', () => { describe('disableViewCourse', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableViewCourse).toBe(expected); + expect(runHook().disableViewCourse).toBe(expected); }; it('disable when hasAccess is false', () => { testDisabled({ hasAccess: false }, true); @@ -129,7 +132,7 @@ describe('useActionDisabledState', () => { describe('disableSelectSession', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableSelectSession).toBe(expected); + expect(runHook().disableSelectSession).toBe(expected); }; it('disable when isEntitlement is false', () => { testDisabled({ isEntitlement: false }, true); @@ -153,6 +156,7 @@ describe('useActionDisabledState', () => { hasAccess: true, canChange: true, hasSessions: true, + availableSessions: ['session1'], }, false, ); @@ -161,7 +165,7 @@ describe('useActionDisabledState', () => { describe('disableCourseTitle', () => { const testDisabled = (data, expected) => { mockHooksData(data); - expect(runHook({ isMasquerading: data.isMasquerading ?? false }).disableCourseTitle).toBe(expected); + expect(runHook().disableCourseTitle).toBe(expected); }; it('disable when isEntitlement is true and isFulfilled is false', () => { testDisabled({ isEntitlement: true, isFulfilled: false }, true); diff --git a/src/containers/CourseCard/hooks.js b/src/containers/CourseCard/hooks.js index b444c9221..76973d5bf 100644 --- a/src/containers/CourseCard/hooks.js +++ b/src/containers/CourseCard/hooks.js @@ -1,23 +1,6 @@ -import { useIntl } from '@openedx/frontend-base'; import { useWindowSize, breakpoints } from '@openedx/paragon'; -import { reduxHooks } from '../../hooks'; export const useIsCollapsed = () => { const { width } = useWindowSize(); return width < breakpoints.small.maxWidth; }; - -export const useCardData = ({ cardId }) => { - const { formatMessage } = useIntl(); - const { title, bannerImgSrc } = reduxHooks.useCardCourseData(cardId); - const { isEnrolled } = reduxHooks.useCardEnrollmentData(cardId); - - return { - isEnrolled, - title, - bannerImgSrc, - formatMessage, - }; -}; - -export default useCardData; diff --git a/src/containers/CourseCard/hooks.test.js b/src/containers/CourseCard/hooks.test.js index fb5ccf7ee..9010873c2 100644 --- a/src/containers/CourseCard/hooks.test.js +++ b/src/containers/CourseCard/hooks.test.js @@ -1,58 +1,32 @@ -import { useIntl } from '@openedx/frontend-base'; +import { renderHook } from '@testing-library/react'; +import { useWindowSize } from '@openedx/paragon'; +import { useIsCollapsed } from './hooks'; -import { reduxHooks } from '@src/hooks'; - -import * as hooks from './hooks'; - -jest.mock('@src/hooks', () => ({ - reduxHooks: { - useCardCourseData: jest.fn(), - useCardEnrollmentData: jest.fn(), +jest.mock('@openedx/paragon', () => ({ + useWindowSize: jest.fn(), + breakpoints: { + small: { + maxWidth: 576, + }, }, })); -jest.mock('@openedx/frontend-base', () => { - const { formatMessage } = jest.requireActual('@src/testUtils'); - return { - ...jest.requireActual('@openedx/frontend-base'), - useIntl: () => ({ - formatMessage, - }), - }; -}); - -const cardId = 'my-test-course-number'; - -describe('CourseCard hooks', () => { - let out; - const { formatMessage } = useIntl(); - beforeEach(() => { +describe('useIsCollapsed', () => { + afterEach(() => { jest.clearAllMocks(); }); - describe('useCardData', () => { - const courseData = { - title: 'fake-title', - bannerImgSrc: 'my-banner-url', - }; - const runHook = ({ course = {} }) => { - reduxHooks.useCardCourseData.mockReturnValueOnce({ - ...courseData, - ...course, - }); - reduxHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: 'test-is-enrolled' }); - out = hooks.useCardData({ cardId }); - }; - beforeEach(() => { - runHook({}); - }); - it('forwards formatMessage from useIntl', () => { - expect(out.formatMessage).toEqual(formatMessage); - }); - it('passes course title and banner URL form course data', () => { - expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(cardId); - expect(out.title).toEqual(courseData.title); - expect(out.bannerImgSrc).toEqual(courseData.bannerImgSrc); - }); + it('should return true when window width is smaller than small breakpoint', () => { + useWindowSize.mockReturnValue({ width: 500 }); + const { result } = renderHook(() => useIsCollapsed()); + expect(result.current).toBe(true); + expect(useWindowSize).toHaveBeenCalled(); + }); + + it('should return false when window width is larger than small breakpoint', () => { + useWindowSize.mockReturnValue({ width: 800 }); + const { result } = renderHook(() => useIsCollapsed()); + expect(result.current).toBe(false); + expect(useWindowSize).toHaveBeenCalled(); }); }); diff --git a/src/containers/CourseFilterControls/ActiveCourseFilters.jsx b/src/containers/CourseFilterControls/ActiveCourseFilters.jsx index ae294618f..17f6e6e6a 100644 --- a/src/containers/CourseFilterControls/ActiveCourseFilters.jsx +++ b/src/containers/CourseFilterControls/ActiveCourseFilters.jsx @@ -1,27 +1,24 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; import { Button, Chip } from '@openedx/paragon'; import { CloseSmall } from '@openedx/paragon/icons'; -import { reduxHooks } from '../../hooks'; +import { useFilters } from '@src/data/context'; import messages from './messages'; import './index.scss'; -export const ActiveCourseFilters = ({ - filters, - handleRemoveFilter, -}) => { +export const ActiveCourseFilters = () => { const { formatMessage } = useIntl(); - const clearFilters = reduxHooks.useClearFilters(); + const { filters, clearFilters, removeFilter } = useFilters(); + return (
{filters.map(filter => ( removeFilter(filter)} > {formatMessage(messages[filter])} @@ -32,9 +29,5 @@ export const ActiveCourseFilters = ({
); }; -ActiveCourseFilters.propTypes = { - filters: PropTypes.arrayOf(PropTypes.string).isRequired, - handleRemoveFilter: PropTypes.func.isRequired, -}; export default ActiveCourseFilters; diff --git a/src/containers/CourseFilterControls/ActiveCourseFilters.test.jsx b/src/containers/CourseFilterControls/ActiveCourseFilters.test.jsx index f938587b1..d54f14498 100644 --- a/src/containers/CourseFilterControls/ActiveCourseFilters.test.jsx +++ b/src/containers/CourseFilterControls/ActiveCourseFilters.test.jsx @@ -1,28 +1,54 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; import { formatMessage } from '@src/testUtils'; +import { useFilters } from '@src/data/context'; import { FilterKeys } from '@src/data/constants/app'; +import userEvent from '@testing-library/user-event'; import ActiveCourseFilters from './ActiveCourseFilters'; import messages from './messages'; const filters = Object.values(FilterKeys); +jest.mock('@src/data/context', () => ({ + useFilters: jest.fn(), +})); + +const removeFiltersMock = jest.fn().mockName('removeFilter'); +const clearFiltersMock = jest.fn().mockName('clearFilters'); +useFilters.mockReturnValue({ + filters, + removeFilter: removeFiltersMock, + clearFilters: clearFiltersMock, +}); + describe('ActiveCourseFilters', () => { - const props = { - filters, - handleRemoveFilter: jest.fn().mockName('handleRemoveFilter'), - }; it('renders chips correctly', () => { - render(); + render(); filters.map((key) => { const chip = screen.getByText(formatMessage(messages[key])); return expect(chip).toBeInTheDocument(); }); }); it('renders button correctly', () => { - render(); + render(); const button = screen.getByRole('button', { name: formatMessage(messages.clearAll) }); expect(button).toBeInTheDocument(); }); -}); \ No newline at end of file + it('should call onClick when button is clicked remove filter', async () => { + const user = userEvent.setup(); + render(); + const removeButton = screen.getByRole('button', { name: formatMessage(messages[filters[0]]) }); + await user.click(removeButton); + expect(removeFiltersMock).toHaveBeenCalledTimes(1); + expect(removeFiltersMock).toHaveBeenCalledWith(filters[0]); + }); + it('should call onClick when button is clicked clear all filters', async () => { + const user = userEvent.setup(); + render(); + screen.debug(); + const clearAllButton = screen.getByRole('button', { name: formatMessage(messages.clearAll) }); + await user.click(clearAllButton); + expect(clearFiltersMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/containers/CourseFilterControls/CourseFilterControls.jsx b/src/containers/CourseFilterControls/CourseFilterControls.jsx index c93cfed9a..07ef06f2d 100644 --- a/src/containers/CourseFilterControls/CourseFilterControls.jsx +++ b/src/containers/CourseFilterControls/CourseFilterControls.jsx @@ -1,7 +1,6 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@openedx/frontend-base'; - +import track from '@src/tracking'; import { Button, Form, @@ -14,44 +13,51 @@ import { } from '@openedx/paragon'; import { Close, Tune } from '@openedx/paragon/icons'; -import { reduxHooks } from '../../hooks'; - +import { useInitializeLearnerHome } from '@src/data/hooks'; +import { useFilters } from '@src/data/context'; import FilterForm from './components/FilterForm'; import SortForm from './components/SortForm'; -import useCourseFilterControlsData from './hooks'; import messages from './messages'; import './index.scss'; -export const CourseFilterControls = ({ - sortBy, - setSortBy, - filters, -}) => { +export const CourseFilterControls = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [targetRef, setTargetRef] = React.useState(null); const { formatMessage } = useIntl(); - const hasCourses = reduxHooks.useHasCourses(); + const { data } = useInitializeLearnerHome(); + const hasCourses = React.useMemo(() => data?.courses?.length > 0, [data]); const { - isOpen, - open, - close, - target, - setTarget, - handleFilterChange, - handleSortChange, - } = useCourseFilterControlsData({ - filters, - setSortBy, - }); + filters, sortBy, setSortBy, addFilter, removeFilter, + } = useFilters(); + + const openFiltersOptions = () => { + track.filter.filterClicked(); + setIsOpen(true); + }; + const closeFiltersOptions = () => { + track.filter.filterOptionSelected(filters); + setIsOpen(false); + }; + + const handleSortChange = (event) => { + setSortBy(event.target.value); + }; + + const handleFilterChange = ({ target: { checked, value } }) => { + const update = checked ? addFilter : removeFilter; + update(value); + }; const { width } = useWindowSize(); const isMobile = width < breakpoints.small.minWidth; return (
@@ -29,7 +32,7 @@ export const FinishedPane = ({ }; FinishedPane.propTypes = { handleClose: PropTypes.func.isRequired, - gaveReason: PropTypes.bool.isRequired, + cardId: PropTypes.string.isRequired, }; export default FinishedPane; diff --git a/src/containers/UnenrollConfirmModal/components/FinishedPane.test.jsx b/src/containers/UnenrollConfirmModal/components/FinishedPane.test.jsx index 977040425..ee9df70a6 100644 --- a/src/containers/UnenrollConfirmModal/components/FinishedPane.test.jsx +++ b/src/containers/UnenrollConfirmModal/components/FinishedPane.test.jsx @@ -1,12 +1,17 @@ import { render, screen } from '@testing-library/react'; import { formatMessage } from '@src/testUtils'; import { IntlProvider } from '@openedx/frontend-base'; +import { useCourseData } from '@src/hooks'; import { FinishedPane } from './FinishedPane'; import messages from './messages'; +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn(), +})); + const props = { - gaveReason: true, + cardId: 'cardId', handleClose: jest.fn().mockName('props.handleClose'), }; @@ -14,6 +19,11 @@ describe('UnenrollConfirmModal FinishedPane', () => { describe('gave reason', () => { beforeEach(() => { jest.clearAllMocks(); + useCourseData.mockReturnValue({ + course: { + courseName: 'Test Course', + }, + }); render(); }); it('renders heading', () => { @@ -25,22 +35,8 @@ describe('UnenrollConfirmModal FinishedPane', () => { expect(returnButton).toBeInTheDocument(); }); it('Gave reason, display thanks message', () => { - const thanksMsg = screen.getByText((text) => text.includes('Thank you')); - expect(thanksMsg).toBeInTheDocument(); - expect(thanksMsg.innerHTML).toContain(formatMessage(messages.finishThanksText)); - }); - }); - describe('Did not give reason', () => { - it('Does not display thanks message', () => { - const customProps = { - gaveReason: false, - handleClose: jest.fn().mockName('props.handleClose'), - }; - render(); - const thanksMsg = screen.queryByText((text) => text.includes('Thank you')); - expect(thanksMsg).toBeNull(); - const finishMsg = screen.getByText(formatMessage(messages.finishText)); - expect(finishMsg).toBeInTheDocument(); + const finishSuccessMessage = screen.getByText((text) => text.includes('Unenrollment Successful')); + expect(finishSuccessMessage).toBeInTheDocument(); }); }); }); diff --git a/src/containers/UnenrollConfirmModal/components/ReasonPane.jsx b/src/containers/UnenrollConfirmModal/components/ReasonPane.jsx index deda9d081..d6db82b1a 100644 --- a/src/containers/UnenrollConfirmModal/components/ReasonPane.jsx +++ b/src/containers/UnenrollConfirmModal/components/ReasonPane.jsx @@ -13,6 +13,7 @@ import messages from './messages'; export const ReasonPane = ({ reason, + handleClose, }) => { const { formatMessage } = useIntl(); const option = (key) => ( @@ -27,6 +28,7 @@ export const ReasonPane = ({ name="unenrollReason" onChange={reason.selectOption} value={reason.selected} + defaultValue={constants.reasonKeys.preferNotToSay} > {constants.order.map(option)} @@ -35,12 +37,13 @@ export const ReasonPane = ({ placeholder={formatMessage(constants.messages.customPlaceholder)} /> + {option(constants.reasonKeys.preferNotToSay)} - - @@ -50,7 +53,6 @@ export const ReasonPane = ({ ReasonPane.propTypes = { reason: PropTypes.shape({ value: PropTypes.string, - handleSkip: PropTypes.func, hasReason: PropTypes.bool, selectOption: PropTypes.func, customOption: PropTypes.shape({ @@ -60,6 +62,7 @@ ReasonPane.propTypes = { selected: PropTypes.string, handleSubmit: PropTypes.func.isRequired, }).isRequired, + handleClose: PropTypes.func.isRequired, }; export default ReasonPane; diff --git a/src/containers/UnenrollConfirmModal/components/ReasonPane.test.jsx b/src/containers/UnenrollConfirmModal/components/ReasonPane.test.jsx index f7ff6ee5d..2cd92f0ba 100644 --- a/src/containers/UnenrollConfirmModal/components/ReasonPane.test.jsx +++ b/src/containers/UnenrollConfirmModal/components/ReasonPane.test.jsx @@ -28,11 +28,11 @@ describe('UnenrollConfirmModal ReasonPane', () => { render(); const radioButtons = screen.getAllByRole('radio'); expect(radioButtons).toBeDefined(); - expect(radioButtons.length).toBe(10); + expect(radioButtons.length).toBe(11); }); - it('render skip button', () => { + it('render cancel button', () => { render(); - const skipButton = screen.getByRole('button', { name: formatMessage(messages.reasonSkip) }); + const skipButton = screen.getByRole('button', { name: formatMessage(messages.confirmCancel) }); expect(skipButton).toBeInTheDocument(); }); it('render submit button', () => { diff --git a/src/containers/UnenrollConfirmModal/components/messages.js b/src/containers/UnenrollConfirmModal/components/messages.js index c74bf2475..426b5d8af 100644 --- a/src/containers/UnenrollConfirmModal/components/messages.js +++ b/src/containers/UnenrollConfirmModal/components/messages.js @@ -4,12 +4,17 @@ const messages = defineMessages({ confirmHeader: { id: 'learner-dash.unenrollConfirm.confirm.header', description: 'Header for confirm unenroll modal', - defaultMessage: 'Unenroll from course?', + defaultMessage: 'Confirm Unenrollment', + }, + confirmText: { + id: 'learner-dash.unenrollConfirm.confirm.text', + description: 'Text for confirm unenroll modal', + defaultMessage: 'Are you sure you want to unenroll from the course {courseTitle} ?', }, confirmCancel: { id: 'learner-dash.unenrollConfirm.confirm.cancel', description: 'Cancel action for confirm unenroll modal', - defaultMessage: 'Never mind', + defaultMessage: 'Cancel', }, confirmUnenroll: { id: 'learner-dash.unenrollConfirm.confirm.unenroll', @@ -19,7 +24,7 @@ const messages = defineMessages({ reasonHeading: { id: 'learner-dash.unenrollConfirm.confirm.reason.heading', description: 'Heading for unenroll reason modal', - defaultMessage: `What's your main reason for unenrolling?`, + defaultMessage: 'Why are you unenrolling?', }, reasonSkip: { id: 'learner-dash.unenrollConfirm.confirm.reason.skip', @@ -29,27 +34,22 @@ const messages = defineMessages({ reasonSubmit: { id: 'learner-dash.unenrollConfirm.confirm.reason.submit', description: 'Submit action for unenroll reason modal', - defaultMessage: 'Submit reason', + defaultMessage: 'Unenroll', }, finishHeading: { id: 'learner-dash.unenrollConfirm.confirm.finish.heading', description: 'Heading for unenroll finish modal', - defaultMessage: 'You are unenrolled', - }, - finishThanksText: { - id: 'learner-dash.unenrollConfirm.confirm.finish.thanks-text', - description: 'Thank you message on unenroll modal for providing a reason', - defaultMessage: 'Thank you for sharing your reason for unenrolling. ', + defaultMessage: 'Unenrollment Successful', }, finishText: { id: 'learner-dash.unenrollConfirm.confirm.finish.text', description: 'Text for unenroll finish modal', - defaultMessage: 'This course will be removed from your dashboard.', + defaultMessage: 'You have been unenrolled from the course {courseTitle}', }, finishReturn: { id: 'learner-dash.unenrollConfirm.confirm.finish.return', description: 'Return action for unenroll finish modal', - defaultMessage: 'Return to dashboard', + defaultMessage: 'Ok', }, }); diff --git a/src/containers/UnenrollConfirmModal/constants.js b/src/containers/UnenrollConfirmModal/constants.js index 868f751e2..49e5eec77 100644 --- a/src/containers/UnenrollConfirmModal/constants.js +++ b/src/containers/UnenrollConfirmModal/constants.js @@ -12,18 +12,19 @@ export const reasonKeys = StrictDict({ quality: 'quality', easy: 'easy', custom: 'custom', + preferNotToSay: 'prefer-not-to-say', }); export const order = [ reasonKeys.prereqs, reasonKeys.difficulty, + reasonKeys.easy, reasonKeys.goals, reasonKeys.broken, reasonKeys.time, reasonKeys.browse, reasonKeys.support, reasonKeys.quality, - reasonKeys.easy, ]; const messages = defineMessages({ @@ -77,6 +78,11 @@ const messages = defineMessages({ description: 'Unenroll custom reason option placeholder text', defaultMessage: 'Other', }, + [reasonKeys.preferNotToSay]: { + id: 'learner-dash.unenrollConfirm.reasons.prefer-not-to-say', + description: 'Unenroll reason option - prefer not to say', + defaultMessage: 'I prefer not to say', + }, }); export default { diff --git a/src/containers/UnenrollConfirmModal/hooks/index.js b/src/containers/UnenrollConfirmModal/hooks/index.js index 85c533421..45315bc6c 100644 --- a/src/containers/UnenrollConfirmModal/hooks/index.js +++ b/src/containers/UnenrollConfirmModal/hooks/index.js @@ -1,14 +1,15 @@ -import { useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import React from 'react'; -import { RequestKeys } from '../../../data/constants/requests'; -import { StrictDict } from '../../../utils'; +import { useAppConfig } from '@openedx/frontend-base'; +import { StrictDict } from '@src/utils'; +import { useCourseData } from '@src/hooks'; +import { useUnenrollFromCourse } from '@src/data/hooks'; import { useUnenrollReasons } from './reasons'; import * as module from '.'; export const state = StrictDict({ - confirmed: (val) => useState(val), // eslint-disable-line + confirmed: (val) => React.useState(val), // eslint-disable-line }); export const modalStates = StrictDict({ @@ -19,14 +20,23 @@ export const modalStates = StrictDict({ export const useUnenrollData = ({ closeModal, cardId }) => { const [isConfirmed, setIsConfirmed] = module.state.confirmed(false); - const confirm = () => setIsConfirmed(true); const reason = useUnenrollReasons({ cardId }); - const queryClient = useQueryClient(); - const refreshList = () => queryClient.invalidateQueries({ queryKey: [RequestKeys.initialize] }); + const appConfig = useAppConfig(); + const courseData = useCourseData(cardId); + const courseId = courseData?.courseRun?.courseId; + + const { mutate: unenrollFromCourse } = useUnenrollFromCourse(); + + const confirm = () => { + if (!appConfig.SHOW_UNENROLL_SURVEY) { + unenrollFromCourse({ courseId }); + } + setIsConfirmed(true); + }; let modalState; if (isConfirmed) { - modalState = (reason.isSubmitted || reason.isSkipped) + modalState = (reason.isSubmitted || !appConfig.SHOW_UNENROLL_SURVEY) ? modalStates.finished : modalStates.reason; } else { modalState = modalStates.confirm; @@ -37,17 +47,13 @@ export const useUnenrollData = ({ closeModal, cardId }) => { setIsConfirmed(false); reason.handleClear(); }; - const closeAndRefresh = () => { - refreshList(); - close(); - }; return { isConfirmed, confirm, reason, close, - closeAndRefresh, + closeAndRefresh: close, modalState, }; }; diff --git a/src/containers/UnenrollConfirmModal/hooks/index.test.js b/src/containers/UnenrollConfirmModal/hooks/index.test.js index eee5acd25..da9c9b951 100644 --- a/src/containers/UnenrollConfirmModal/hooks/index.test.js +++ b/src/containers/UnenrollConfirmModal/hooks/index.test.js @@ -1,40 +1,38 @@ -import React from 'react'; -import { apiHooks } from '@src/hooks'; import { MockUseState } from '@src/testUtils'; +import { useAppConfig } from '@openedx/frontend-base'; + +import { useCourseData } from '@src/hooks'; +import { useUnenrollFromCourse } from '@src/data/hooks'; + import * as reasons from './reasons'; import * as hooks from '.'; -import { renderHook } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; jest.mock('./reasons', () => ({ useUnenrollReasons: jest.fn(), })); -jest.mock('../../../hooks', () => ({ - apiHooks: { - useInitializeApp: jest.fn(), - }, + +jest.mock('@src/data/hooks', () => ({ + useUnenrollFromCourse: jest.fn(), })); -jest.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ - invalidateQueries: jest.fn(), - }), - QueryClient: jest.fn().mockImplementation(() => ({ - invalidateQueries: jest.fn(), - })), - QueryClientProvider: ({ children }) => children, + +jest.mock('@src/hooks', () => ({ + useCourseData: jest.fn(), +})); + +jest.mock('@openedx/frontend-base', () => ({ + useAppConfig: jest.fn(), })); const state = new MockUseState(hooks); const testValue = 'test-value'; -const initializeApp = jest.fn(); -apiHooks.useInitializeApp.mockReturnValue(initializeApp); - +const unenrollFromCourse = jest.fn(); +useUnenrollFromCourse.mockReturnValue({ mutate: unenrollFromCourse }); +useCourseData.mockReturnValue({ courseRun: { courseId: 'test-course-id' } }); let out; const mockReason = { handleClear: jest.fn(), isSubmitted: false, - isSkipped: false, submittedReason: 'test-submitted-reason', }; @@ -43,18 +41,12 @@ const useUnenrollReasons = jest.fn(() => mockReason); describe('UnenrollConfirmModal hooks', () => { beforeEach(() => { reasons.useUnenrollReasons.mockImplementation(useUnenrollReasons); + useAppConfig.mockReturnValue({ SHOW_UNENROLL_SURVEY: true }); }); const closeModal = jest.fn(); const cardId = 'test-card-id'; - const createUseUnenrollData = () => { - const queryClient = new QueryClient(); - const { result } = renderHook(() => hooks.useUnenrollData({ closeModal, cardId }), { - wrapper: ({ children }) => ( - {children} - ), - }); - return result.current; - }; + + const createUseUnenrollData = () => hooks.useUnenrollData({ closeModal, cardId }); describe('state fields', () => { state.testGetter(state.keys.confirmed); @@ -87,63 +79,72 @@ describe('UnenrollConfirmModal hooks', () => { }); }); describe('closeAndRefresh', () => { - beforeEach(() => { - apiHooks.useInitializeApp.mockClear(); - }); - it('calls closeModal, sets isConfirmed to false, and calls reason.handleClear', () => { + it('behaves the same as close', () => { out.closeAndRefresh(); expect(closeModal).toHaveBeenCalled(); expect(state.setState.confirmed).toHaveBeenCalledWith(false); expect(mockReason.handleClear).toHaveBeenCalled(); }); - it('calls refreshList and close', () => { - const refreshList = jest.fn(); - const close = jest.fn(); - - jest.spyOn(hooks, 'useUnenrollData').mockReturnValue({ - closeAndRefresh: () => { - refreshList(); - close(); - }, - refreshList, - close, - }); - - out = hooks.useUnenrollData({ closeModal, cardId }); - out.closeAndRefresh(); - expect(refreshList).toHaveBeenCalled(); - expect(close).toHaveBeenCalled(); - }); }); }); - describe('modalState', () => { - // Helper function to compute modalState based on the same logic as the actual hook - const getModalState = (isConfirmed, reason) => { - if (isConfirmed) { - return (reason.isSubmitted || reason.isSkipped) ? 'finished' : 'reason'; - } - return 'confirm'; - }; - - test('should return finished when confirmed and submitted', () => { - const result = getModalState(true, { isSubmitted: true, isSkipped: false }); - expect(result).toEqual('finished'); + describe('SHOW_UNENROLL_SURVEY configuration tests', () => { + beforeEach(() => { + state.mock(); + jest.clearAllMocks(); + useCourseData.mockReturnValue({ courseRun: { courseId: 'test-course-id' } }); + useUnenrollFromCourse.mockReturnValue({ mutate: unenrollFromCourse }); + reasons.useUnenrollReasons.mockImplementation(useUnenrollReasons); }); - - test('should return finished when confirmed and skipped', () => { - const result = getModalState(true, { isSubmitted: false, isSkipped: true }); - expect(result).toEqual('finished'); + afterEach(() => { + state.restore(); }); - test('should return reason when confirmed but not submitted or skipped', () => { - const result = getModalState(true, { isSubmitted: false, isSkipped: false }); - expect(result).toEqual('reason'); + describe('when SHOW_UNENROLL_SURVEY is true (default)', () => { + beforeEach(() => { + useAppConfig.mockReturnValue({ SHOW_UNENROLL_SURVEY: true }); + }); + + test('confirm does not call unenrollFromCourse immediately', () => { + out = createUseUnenrollData(); + out.confirm(); + expect(unenrollFromCourse).not.toHaveBeenCalled(); + expect(state.setState.confirmed).toHaveBeenCalledWith(true); + }); + + test('modalState returns reason when confirmed but not submitted', () => { + state.mockVal(state.keys.confirmed, true); + reasons.useUnenrollReasons.mockReturnValueOnce({ ...mockReason, isSubmitted: false }); + out = createUseUnenrollData(); + expect(out.modalState).toEqual(hooks.modalStates.reason); + }); + + test('modalState returns finished when confirmed and submitted', () => { + state.mockVal(state.keys.confirmed, true); + reasons.useUnenrollReasons.mockReturnValueOnce({ ...mockReason, isSubmitted: true }); + out = createUseUnenrollData(); + expect(out.modalState).toEqual(hooks.modalStates.finished); + }); }); - test('should return confirm when not confirmed', () => { - const result = getModalState(false, { isSubmitted: false, isSkipped: false }); - expect(result).toEqual('confirm'); + describe('when SHOW_UNENROLL_SURVEY is false', () => { + beforeEach(() => { + useAppConfig.mockReturnValue({ SHOW_UNENROLL_SURVEY: false }); + }); + + test('confirm calls unenrollFromCourse immediately', () => { + out = createUseUnenrollData(); + out.confirm(); + expect(unenrollFromCourse).toHaveBeenCalled(); + expect(state.setState.confirmed).toHaveBeenCalledWith(true); + }); + + test('modalState returns finished when confirmed regardless of submission status', () => { + state.mockVal(state.keys.confirmed, true); + reasons.useUnenrollReasons.mockReturnValueOnce({ ...mockReason, isSubmitted: false }); + out = createUseUnenrollData(); + expect(out.modalState).toEqual(hooks.modalStates.finished); + }); }); }); }); diff --git a/src/containers/UnenrollConfirmModal/hooks/reasons.js b/src/containers/UnenrollConfirmModal/hooks/reasons.js index a288496fc..aefa8960a 100644 --- a/src/containers/UnenrollConfirmModal/hooks/reasons.js +++ b/src/containers/UnenrollConfirmModal/hooks/reasons.js @@ -1,18 +1,19 @@ import React from 'react'; import { - apiHooks, - reduxHooks, + useCourseData, + useCourseTrackingEvent, utilHooks, -} from '../../../hooks'; -import { StrictDict } from '../../../utils'; -import track from '../../../tracking'; - + useEntitlementInfo, +} from '@src/hooks'; +import { StrictDict } from '@src/utils'; +import track from '@src/tracking'; +import { useUnenrollFromCourse } from '@src/data/hooks'; import * as module from './reasons'; +import constants from '../constants'; export const state = StrictDict({ customOption: (val) => React.useState(val), // eslint-disable-line - isSkipped: (val) => React.useState(val), // eslint-disable-line selectedReason: (val) => React.useState(val), // eslint-disable-line isSubmitted: (val) => React.useState(val), //eslint-disable-line }); @@ -20,42 +21,41 @@ export const state = StrictDict({ export const useUnenrollReasons = ({ cardId, }) => { + const courseData = useCourseData(cardId); + const { mutate: unenrollFromCourseMutation } = useUnenrollFromCourse(); // The selected option element from the menu - const [selectedReason, setSelectedReason] = module.state.selectedReason(null); + const [selectedReason, setSelectedReason] = module.state.selectedReason( + constants.reasonKeys.preferNotToSay, + ); // Custom option element entry value const [customOption, setCustomOption] = module.state.customOption(''); - // Did the user choose to skip selecting a reason? - const [isSkipped, setIsSkipped] = module.state.isSkipped(false); // Did the user submit an unenrollment reason const [isSubmitted, setIsSubmitted] = module.state.isSubmitted(false); - const { isEntitlement } = reduxHooks.useCardEntitlementData(cardId); + const { isEntitlement } = useEntitlementInfo(courseData); const submittedReason = selectedReason === 'custom' ? customOption : selectedReason; const hasReason = ![null, ''].includes(submittedReason); - const handleTrackReasons = reduxHooks.useTrackCourseEvent( + const handleTrackReasons = useCourseTrackingEvent( track.engagement.unenrollReason, cardId, submittedReason, isEntitlement, ); - const unenrollFromCourse = apiHooks.useUnenrollFromCourse(cardId); + const unenrollFromCourse = () => { + const courseId = courseData?.courseRun?.courseId; + unenrollFromCourseMutation({ courseId }); + }; const handleClear = () => { setSelectedReason(null); setCustomOption(''); - setIsSkipped(false); setIsSubmitted(false); }; - const handleSkip = () => { - setIsSkipped(true); - unenrollFromCourse(); - }; - const handleSubmit = (e) => { handleTrackReasons(e); setIsSubmitted(true); @@ -68,10 +68,8 @@ export const useUnenrollReasons = ({ return { customOption: { value: customOption, onChange: handleCustomOptionChange }, handleClear, - handleSkip, handleSubmit, hasReason, - isSkipped, isSubmitted, selectOption: handleSelectOption, submittedReason, diff --git a/src/containers/UnenrollConfirmModal/hooks/reasons.test.js b/src/containers/UnenrollConfirmModal/hooks/reasons.test.js deleted file mode 100644 index 6b0e0fb71..000000000 --- a/src/containers/UnenrollConfirmModal/hooks/reasons.test.js +++ /dev/null @@ -1,192 +0,0 @@ -import { MockUseState } from '@src/testUtils'; -import track from '@src/tracking'; -import { - apiHooks, - reduxHooks, - utilHooks, -} from '@src/hooks'; - -import * as hooks from './reasons'; - -jest.mock('@src/hooks', () => ({ - apiHooks: { - useUnenrollFromCourse: jest.fn((...args) => ({ unenrollFromCourse: args })), - }, - reduxHooks: { - useCardEntitlementData: jest.fn(), - useTrackCourseEvent: jest.fn(), - }, - utilHooks: { - useValueCallback: jest.fn((cb, prereqs) => ({ useValueCallback: { cb, prereqs } })), - }, -})); - -const state = new MockUseState(hooks); -const testValue = 'test-value'; -const testValue2 = 'test-value2'; -const unenrollFromCourse = jest.fn((...args) => ({ unenrollFromCourse: args })); -const trackCourseEvent = jest.fn((e) => ({ courseEvent: e })); -apiHooks.useUnenrollFromCourse.mockReturnValue(unenrollFromCourse); -reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent); -let out; - -const cardId = 'test-card-id'; -const loadHook = (isEntitlement = false) => { - reduxHooks.useCardEntitlementData.mockReturnValue({ isEntitlement }); - out = hooks.useUnenrollReasons({ cardId }); -}; - -describe('UnenrollConfirmModal reasons hooks', () => { - describe('state fields', () => { - state.testGetter(state.keys.customOption); - state.testGetter(state.keys.isSkipped); - state.testGetter(state.keys.isSubmitted); - state.testGetter(state.keys.selectedReason); - }); - describe('useUnenrollReasons', () => { - beforeEach(() => { - jest.clearAllMocks(); - state.mock(); - loadHook(); - }); - afterEach(() => { - state.restore(); - }); - describe('behavior', () => { - describe('state fields', () => { - it('initializes selectedReason with null', () => { - state.expectInitializedWith(state.keys.selectedReason, null); - }); - it('initializes customOption with empty string', () => { - state.expectInitializedWith(state.keys.customOption, ''); - }); - it('initializes isSkipped with false', () => { - state.expectInitializedWith(state.keys.isSkipped, false); - }); - it('initializes isSubmitted with false', () => { - state.expectInitializedWith(state.keys.isSubmitted, false); - }); - }); - describe('useTrackCourseEvent inititalization', () => { - it('passes custom option if selectedReason is custom', () => { - state.mockVal(state.keys.selectedReason, 'custom'); - state.mockVal(state.keys.customOption, testValue); - loadHook(); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( - track.engagement.unenrollReason, - cardId, - testValue, - false, // isEntitlement - ); - }); - it('passes selected reason if not custom', () => { - state.mockVal(state.keys.selectedReason, testValue2); - state.mockVal(state.keys.customOption, testValue); - loadHook(true); - expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( - track.engagement.unenrollReason, - cardId, - testValue2, - true, // isEntitlement - ); - }); - }); - it('initializes card entitlement data with cardId', () => { - expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId); - }); - it('initializes unenerollFromCourse event with cardId', () => { - expect(apiHooks.useUnenrollFromCourse).toHaveBeenCalledWith(cardId); - }); - }); - describe('output', () => { - describe('customOption', () => { - test('customOption.value returns custom option', () => { - state.mockVal(state.keys.customOption, testValue); - loadHook(); - expect(out.customOption.value).toEqual(testValue); - }); - test('customOption.onChange returns valueCallback for setCustomOption', () => { - expect(out.customOption.onChange).toEqual( - utilHooks.useValueCallback(state.setState.customOption), - ); - }); - }); - describe('hasReason', () => { - it('returns true if an option is selected other than custom', () => { - state.mockVal(state.keys.selectedReason, testValue); - loadHook(); - expect(out.hasReason).toEqual(true); - }); - it('returns true if custom option is selected and provided', () => { - state.mockVal(state.keys.selectedReason, 'custom'); - state.mockVal(state.keys.customOption, testValue2); - loadHook(); - expect(out.hasReason).toEqual(true); - }); - it('returns false if no option is selected', () => { - state.mockVal(state.keys.selectedReason, null); - loadHook(); - expect(out.hasReason).toEqual(false); - }); - it('returns false if custom option is selcted but not provided', () => { - state.mockVal(state.keys.selectedReason, 'custom'); - state.mockVal(state.keys.customOption, ''); - loadHook(); - expect(out.hasReason).toEqual(false); - }); - }); - describe('handleClear method', () => { - it('resets selected and submitted reasons, custom option and isSkipped', () => { - out.handleClear(); - expect(state.setState.selectedReason).toHaveBeenCalledWith(null); - expect(state.setState.customOption).toHaveBeenCalledWith(''); - expect(state.setState.isSkipped).toHaveBeenCalledWith(false); - expect(state.setState.isSubmitted).toHaveBeenCalledWith(false); - }); - }); - test('handleSkip sets isSkipped and isSubmitted, and unenrolls w/out a reason', () => { - out.handleSkip(); - expect(state.setState.isSkipped).toHaveBeenCalledWith(true); - expect(unenrollFromCourse).toHaveBeenCalledWith(); - }); - describe('handleSubmit', () => { - it('tracks reason event and calls unenroll action', () => { - state.mockVal(state.keys.selectedReason, testValue); - loadHook(); - expect(trackCourseEvent).not.toHaveBeenCalled(); - const event = { test: 'event' }; - out.handleSubmit(event); - expect(trackCourseEvent).toHaveBeenCalledWith(event); - expect(unenrollFromCourse).toHaveBeenCalledWith(); - }); - }); - test('isSkipped returns state value', () => { - state.mockVal(state.keys.isSkipped, testValue); - loadHook(); - expect(out.isSkipped).toEqual(testValue); - }); - test('isSubmitted returns state value', () => { - state.mockVal(state.keys.isSubmitted, testValue); - loadHook(); - expect(out.isSubmitted).toEqual(testValue); - }); - test('selectedOption returns valueCallback for setSelectedReason', () => { - expect(out.selectOption).toEqual( - utilHooks.useValueCallback(state.setState.selectedReason), - ); - }); - describe('submittedReason', () => { - it('returns the selected reason unless is custom, then shows custom option', () => { - state.mockVal(state.keys.selectedReason, testValue); - state.mockVal(state.keys.customOption, testValue2); - loadHook(); - expect(out.submittedReason).toEqual(testValue); - state.mockVal(state.keys.selectedReason, 'custom'); - state.mockVal(state.keys.customOption, testValue2); - loadHook(); - expect(out.submittedReason).toEqual(testValue2); - }); - }); - }); - }); -}); diff --git a/src/containers/UnenrollConfirmModal/hooks/reasons.test.jsx b/src/containers/UnenrollConfirmModal/hooks/reasons.test.jsx new file mode 100644 index 000000000..5be525f85 --- /dev/null +++ b/src/containers/UnenrollConfirmModal/hooks/reasons.test.jsx @@ -0,0 +1,262 @@ +import { renderHook, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import track from '@src/tracking'; +import { + useCourseData, + useCourseTrackingEvent, + useEntitlementInfo, +} from '@src/hooks'; +import * as api from '@src/data/services/lms/api'; + +import { useUnenrollReasons } from './reasons'; +import constants from '../constants'; + +jest.mock('@src/data/services/lms/api', () => ({ + unenrollFromCourse: jest.fn(), +})); + +jest.mock('@src/hooks', () => ({ + useCourseTrackingEvent: jest.fn(), + useCourseData: jest.fn(), + useEntitlementInfo: jest.fn(), + utilHooks: { + useValueCallback: jest.fn((cb) => cb), + }, +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }) => {children}; + return wrapper; +}; + +const cardId = 'test-card-id'; +const courseData = { courseRun: { courseId: cardId } }; +const trackCourseEvent = jest.fn(); + +describe('UnenrollConfirmModal reasons hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + useCourseData.mockReturnValue(courseData); + useCourseTrackingEvent.mockReturnValue(trackCourseEvent); + useEntitlementInfo.mockReturnValue({ isEntitlement: false }); + api.unenrollFromCourse.mockResolvedValue({}); + }); + + describe('useUnenrollReasons', () => { + it('initializes selectedReason with preferNotToSay constant', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + expect(result.current.submittedReason).toBe(constants.reasonKeys.preferNotToSay); + }); + + it('initializes customOption with empty string', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + expect(result.current.customOption.value).toBe(''); + }); + + it('initializes isSubmitted with false', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + expect(result.current.isSubmitted).toBe(false); + }); + + it('passes custom option as track event value when selectedReason is custom', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.selectOption('custom'); + }); + + act(() => { + result.current.customOption.onChange('test-custom-value'); + }); + + renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + expect(useCourseTrackingEvent).toHaveBeenCalledWith( + track.engagement.unenrollReason, + cardId, + 'test-custom-value', + false, + ); + }); + + it('passes selected reason as track event value when not custom with entitlement', () => { + useEntitlementInfo.mockReturnValue({ isEntitlement: true }); + + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.selectOption('test-reason'); + }); + + renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + expect(useCourseTrackingEvent).toHaveBeenCalledWith( + track.engagement.unenrollReason, + cardId, + 'test-reason', + true, + ); + }); + + it('initializes card entitlement data with courseData', () => { + renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + expect(useEntitlementInfo).toHaveBeenCalledWith(courseData); + }); + + describe('customOption', () => { + it('returns current custom option value', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.customOption.onChange('test-value'); + }); + + expect(result.current.customOption.value).toBe('test-value'); + }); + }); + + describe('hasReason', () => { + it('returns true if an option is selected other than custom', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.selectOption('test-value'); + }); + + expect(result.current.hasReason).toBe(true); + }); + + it('returns true if custom option is selected and provided', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.selectOption('custom'); + }); + + act(() => { + result.current.customOption.onChange('test-value2'); + }); + + expect(result.current.hasReason).toBe(true); + }); + + it('returns false if no option is selected', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.selectOption(null); + }); + + expect(result.current.hasReason).toBe(false); + }); + + it('returns false if custom option is selected but not provided', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.selectOption('custom'); + }); + + expect(result.current.hasReason).toBe(false); + }); + }); + + describe('handleClear method', () => { + it('resets selected reason, custom option and isSubmitted', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.selectOption('test-reason'); + }); + + act(() => { + result.current.customOption.onChange('test-value'); + }); + + act(() => { + result.current.handleClear(); + }); + + expect(result.current.submittedReason).toBeNull(); + expect(result.current.customOption.value).toBe(''); + expect(result.current.isSubmitted).toBe(false); + }); + }); + + describe('handleSubmit', () => { + it('tracks reason event and calls unenroll api', async () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + const event = { test: 'event' }; + + await act(async () => { + result.current.handleSubmit(event); + }); + + expect(trackCourseEvent).toHaveBeenCalledWith(event); + expect(api.unenrollFromCourse).toHaveBeenCalledWith({ courseId: cardId }); + }); + }); + + describe('submittedReason', () => { + it('returns the selected reason unless is custom, then shows custom option', () => { + const { result } = renderHook(() => useUnenrollReasons({ cardId }), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.selectOption('test-value'); + }); + + act(() => { + result.current.customOption.onChange('test-value2'); + }); + + expect(result.current.submittedReason).toBe('test-value'); + + act(() => { + result.current.selectOption('custom'); + }); + + expect(result.current.submittedReason).toBe('test-value2'); + }); + }); + }); +}); diff --git a/src/containers/UnenrollConfirmModal/index.jsx b/src/containers/UnenrollConfirmModal/index.jsx index 96eba201e..ce4231dd7 100644 --- a/src/containers/UnenrollConfirmModal/index.jsx +++ b/src/containers/UnenrollConfirmModal/index.jsx @@ -38,13 +38,13 @@ export const UnenrollConfirmModal = ({ style={{ textAlign: 'start' }} > {(modalState === modalStates.confirm) && ( - + )} {(modalState === modalStates.finished) && ( - + )} {(modalState === modalStates.reason) && ( - + )}
diff --git a/src/containers/UnenrollConfirmModal/index.test.jsx b/src/containers/UnenrollConfirmModal/index.test.jsx index b31e7f69a..ce68be9d4 100644 --- a/src/containers/UnenrollConfirmModal/index.test.jsx +++ b/src/containers/UnenrollConfirmModal/index.test.jsx @@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react'; import { formatMessage } from '@src/testUtils'; import { IntlProvider } from '@openedx/frontend-base'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import { UnenrollConfirmModal } from '.'; - import * as hooks from './hooks'; import messages from './components/messages'; @@ -13,11 +13,17 @@ jest.mock('./hooks', () => ({ useUnenrollData: jest.fn(), })); +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), +})); + +const mockRefreshList = jest.fn(); +useInitializeLearnerHome.mockReturnValue({ refetch: mockRefreshList }); + describe('UnenrollConfirmModal component', () => { const hookProps = { confirm: jest.fn().mockName('hooks.confirm'), reason: { - isSkipped: false, reasonProps: 'other', }, close: jest.fn().mockName('hooks.close'), @@ -49,22 +55,20 @@ describe('UnenrollConfirmModal component', () => { render(); const finishHeading = screen.getByText(formatMessage(messages.finishHeading)); expect(finishHeading).toBeInTheDocument(); - const thanksMsg = screen.getByText((text) => text.includes('Thank you')); - expect(thanksMsg).toBeInTheDocument(); - expect(thanksMsg.innerHTML).toContain(formatMessage(messages.finishThanksText)); + const finishMsg = screen.getByText((text) => text.includes('You have been unenrolled from the course')); + expect(finishMsg).toBeInTheDocument(); }); - it('modalStates.finished, reason skipped', () => { + it('modalStates.finished, cancel unenrollment', () => { hooks.useUnenrollData.mockReturnValueOnce({ ...hookProps, modalState: hooks.modalStates.finished, - reason: { isSkipped: true }, }); render(); const finishHeading = screen.getByText(formatMessage(messages.finishHeading)); expect(finishHeading).toBeInTheDocument(); - const thanksMsg = screen.queryByText((text) => text.includes('Thank you')); - expect(thanksMsg).toBeNull(); - const finishMsg = screen.getByText(formatMessage(messages.finishText)); + const okButton = screen.queryByText((text) => text.includes('Ok')); + expect(okButton).toBeInTheDocument(); + const finishMsg = screen.queryByText('You have been unenrolled from the course'); expect(finishMsg).toBeInTheDocument(); }); it('modalStates.reason, should display correct component with no shadow', () => { diff --git a/src/data/constants/app.test.js b/src/data/constants/app.test.js index 3fbd61ee3..a1935e362 100644 --- a/src/data/constants/app.test.js +++ b/src/data/constants/app.test.js @@ -1,4 +1,7 @@ import * as constants from './app'; +import { requestStatuses } from './credit'; +import fileConstants, { FileTypes, downloadSingleLimit, downloadAllLimit } from './files'; +import htmlKeysConstants, { buttonStates, htmlProps } from './htmlKeys'; jest.unmock('./app'); @@ -9,4 +12,164 @@ describe('app constants', () => { expect(constants.locationId).toEqual(window.location.pathname.slice(1)); window.location = old; }); + + describe('credit constants', () => { + describe('requestStatuses', () => { + it('should have correct pending status', () => { + expect(requestStatuses.pending).toBe('pending'); + }); + + it('should have correct approved status', () => { + expect(requestStatuses.approved).toBe('approved'); + }); + + it('should have correct rejected status', () => { + expect(requestStatuses.rejected).toBe('rejected'); + }); + + it('should have all expected status properties', () => { + const expectedStatuses = ['pending', 'approved', 'rejected']; + const actualStatuses = Object.keys(requestStatuses); + + expect(actualStatuses).toEqual(expect.arrayContaining(expectedStatuses)); + expect(actualStatuses).toHaveLength(expectedStatuses.length); + }); + + it('should have string values for all statuses', () => { + Object.values(requestStatuses).forEach(status => { + expect(typeof status).toBe('string'); + }); + }); + }); + }); + + describe('files constants', () => { + describe('FileTypes', () => { + it('should have correct file type values', () => { + expect(FileTypes.pdf).toBe('pdf'); + expect(FileTypes.jpg).toBe('jpg'); + expect(FileTypes.jpeg).toBe('jpeg'); + expect(FileTypes.png).toBe('png'); + expect(FileTypes.bmp).toBe('bmp'); + expect(FileTypes.txt).toBe('txt'); + expect(FileTypes.gif).toBe('gif'); + expect(FileTypes.jfif).toBe('jfif'); + expect(FileTypes.pjpeg).toBe('pjpeg'); + expect(FileTypes.pjp).toBe('pjp'); + expect(FileTypes.svg).toBe('svg'); + }); + + it('should have all expected file types', () => { + const expectedTypes = ['pdf', 'jpg', 'jpeg', 'png', 'bmp', 'txt', 'gif', 'jfif', 'pjpeg', 'pjp', 'svg']; + const actualTypes = Object.keys(FileTypes); + + expect(actualTypes).toEqual(expect.arrayContaining(expectedTypes)); + expect(actualTypes).toHaveLength(expectedTypes.length); + }); + + it('should have string values for all file types', () => { + Object.values(FileTypes).forEach(fileType => { + expect(typeof fileType).toBe('string'); + }); + }); + }); + + describe('download limits', () => { + it('should have correct downloadSingleLimit value', () => { + expect(downloadSingleLimit).toBe(1610612736); + }); + + it('should have correct downloadAllLimit value', () => { + expect(downloadAllLimit).toBe(10737418240); + }); + + it('should have downloadAllLimit greater than downloadSingleLimit', () => { + expect(downloadAllLimit).toBeGreaterThan(downloadSingleLimit); + }); + + it('should have numeric values for download limits', () => { + expect(typeof downloadSingleLimit).toBe('number'); + expect(typeof downloadAllLimit).toBe('number'); + }); + }); + + describe('default export', () => { + it('should export FileTypes as default', () => { + expect(fileConstants).toBe(FileTypes); + }); + + it('should be the same as named export', () => { + expect(fileConstants).toEqual(FileTypes); + }); + }); + }); + + describe('htmlKeys constants', () => { + describe('buttonStates', () => { + it('should have correct button state values', () => { + expect(buttonStates.default).toBe('default'); + expect(buttonStates.pending).toBe('pending'); + expect(buttonStates.error).toBe('error'); + }); + + it('should have all expected button states', () => { + const expectedStates = ['default', 'pending', 'error']; + const actualStates = Object.keys(buttonStates); + + expect(actualStates).toEqual(expect.arrayContaining(expectedStates)); + expect(actualStates).toHaveLength(expectedStates.length); + }); + + it('should have string values for all button states', () => { + Object.values(buttonStates).forEach(state => { + expect(typeof state).toBe('string'); + }); + }); + }); + + describe('htmlProps', () => { + it('should have correct html property values', () => { + expect(htmlProps.disabled).toBe('disabled'); + expect(htmlProps.href).toBe('href'); + expect(htmlProps.onClick).toBe('onClick'); + expect(htmlProps.onChange).toBe('onChange'); + expect(htmlProps.onBlur).toBe('onBlur'); + expect(htmlProps.size).toBe('size'); + }); + + it('should have all expected html properties', () => { + const expectedProps = ['disabled', 'href', 'onClick', 'onChange', 'onBlur', 'size']; + const actualProps = Object.keys(htmlProps); + + expect(actualProps).toEqual(expect.arrayContaining(expectedProps)); + expect(actualProps).toHaveLength(expectedProps.length); + }); + + it('should have string values for all html properties', () => { + Object.values(htmlProps).forEach(prop => { + expect(typeof prop).toBe('string'); + }); + }); + }); + + describe('default export', () => { + it('should export buttonStates and htmlProps in default object', () => { + expect(htmlKeysConstants.buttonStates).toBe(buttonStates); + expect(htmlKeysConstants.htmlProps).toBe(htmlProps); + }); + + it('should have both properties', () => { + expect(htmlKeysConstants).toHaveProperty('buttonStates'); + expect(htmlKeysConstants).toHaveProperty('htmlProps'); + }); + + it('should only have expected properties', () => { + const expectedKeys = ['buttonStates', 'htmlProps']; + const actualKeys = Object.keys(htmlKeysConstants); + + expect(actualKeys).toEqual(expect.arrayContaining(expectedKeys)); + expect(actualKeys).toHaveLength(expectedKeys.length); + }); + }); + }); }); diff --git a/src/data/constants/files.js b/src/data/constants/files.js index 46bc5a34f..22d2dd999 100644 --- a/src/data/constants/files.js +++ b/src/data/constants/files.js @@ -1,4 +1,4 @@ -import { StrictDict } from 'utils'; +import { StrictDict } from '@src/utils'; export const FileTypes = StrictDict({ pdf: 'pdf', diff --git a/src/data/constants/htmlKeys.js b/src/data/constants/htmlKeys.js index f384adace..2c051260f 100644 --- a/src/data/constants/htmlKeys.js +++ b/src/data/constants/htmlKeys.js @@ -1,4 +1,4 @@ -import { StrictDict } from 'utils'; +import { StrictDict } from '@src/utils'; export const buttonStates = StrictDict({ default: 'default', diff --git a/src/data/context/Filters.test.tsx b/src/data/context/Filters.test.tsx new file mode 100644 index 000000000..66ceead0b --- /dev/null +++ b/src/data/context/Filters.test.tsx @@ -0,0 +1,771 @@ +import type { ReactNode } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { FiltersProvider, useFilters } from './FiltersProvider'; + +describe('FiltersProvider and useFilters', () => { + const createWrapper = () => function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + + describe('useFilters hook', () => { + describe('initial state', () => { + it('should return initial filters state', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + expect(result.current.filters).toEqual([]); + expect(result.current.sortBy).toBe('enrolled'); + expect(result.current.pageNumber).toBe(1); + expect(typeof result.current.setFilters).toBe('function'); + expect(typeof result.current.addFilter).toBe('function'); + expect(typeof result.current.removeFilter).toBe('function'); + expect(typeof result.current.clearFilters).toBe('function'); + expect(typeof result.current.setSortBy).toBe('function'); + expect(typeof result.current.setPageNumber).toBe('function'); + }); + + it('should have all expected properties in context', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const expectedProperties = [ + 'filters', + 'sortBy', + 'pageNumber', + 'setFilters', + 'addFilter', + 'removeFilter', + 'clearFilters', + 'setSortBy', + 'setPageNumber', + ]; + + expectedProperties.forEach(prop => { + expect(result.current).toHaveProperty(prop); + }); + }); + }); + + describe('error handling', () => { + it('should throw error when used outside of provider', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useFilters()); + }).toThrow('useFilters must be used within a FiltersProvider'); + + consoleErrorSpy.mockRestore(); + }); + + it('should throw error with correct message when context is null', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + renderHook(() => useFilters()); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('useFilters must be used within a FiltersProvider'); + } + + consoleErrorSpy.mockRestore(); + }); + }); + }); + + describe('filters management', () => { + describe('setFilters', () => { + it('should set filters correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const testFilters = ['inProgress', 'upgraded']; + + act(() => { + result.current.setFilters(testFilters); + }); + + expect(result.current.filters).toEqual(testFilters); + }); + + it('should handle single filter', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['notStarted']); + }); + + expect(result.current.filters).toEqual(['notStarted']); + }); + + it('should handle empty filters array', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + act(() => { + result.current.setFilters(['inProgress', 'done']); + }); + act(() => { + result.current.setFilters([]); + }); + + expect(result.current.filters).toEqual([]); + }); + + it('should replace existing filters completely', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['inProgress']); + }); + + act(() => { + result.current.setFilters(['done', 'upgraded']); + }); + + expect(result.current.filters).toEqual(['done', 'upgraded']); + }); + + it('should handle multiple filter types', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const multipleFilters = [ + 'inProgress', + 'notStarted', + 'done', + 'upgraded', + 'notEnrolled', + ]; + + act(() => { + result.current.setFilters(multipleFilters); + }); + + expect(result.current.filters).toEqual(multipleFilters); + }); + }); + + describe('addFilter', () => { + it('should add filter to empty filters array', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.addFilter('inProgress'); + }); + + expect(result.current.filters).toEqual(['inProgress']); + }); + + it('should add filter to existing filters', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['inProgress']); + }); + + act(() => { + result.current.addFilter('upgraded'); + }); + + expect(result.current.filters).toEqual(['inProgress', 'upgraded']); + }); + + it('should add duplicate filters (no deduplication)', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.addFilter('inProgress'); + }); + + act(() => { + result.current.addFilter('inProgress'); + }); + + expect(result.current.filters).toEqual(['inProgress', 'inProgress']); + }); + + it('should add multiple filters sequentially', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const filtersToAdd = ['inProgress', 'upgraded', 'done']; + + filtersToAdd.forEach((filter, index) => { + act(() => { + result.current.addFilter(filter); + }); + expect(result.current.filters).toHaveLength(index + 1); + expect(result.current.filters).toContain(filter); + }); + + expect(result.current.filters).toEqual(filtersToAdd); + }); + }); + + describe('removeFilter', () => { + it('should remove specific filter from filters array', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['inProgress', 'upgraded', 'done']); + }); + + act(() => { + result.current.removeFilter('upgraded'); + }); + + expect(result.current.filters).toEqual(['inProgress', 'done']); + }); + + it('should handle removing non-existent filter', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['inProgress', 'upgraded']); + }); + + act(() => { + result.current.removeFilter('nonExistent'); + }); + + expect(result.current.filters).toEqual(['inProgress', 'upgraded']); + }); + + it('should remove filter from empty array', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.removeFilter('inProgress'); + }); + + expect(result.current.filters).toEqual([]); + }); + + it('should remove last filter leaving empty array', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['inProgress']); + }); + + act(() => { + result.current.removeFilter('inProgress'); + }); + + expect(result.current.filters).toEqual([]); + }); + }); + + describe('clearFilters', () => { + it('should clear all filters', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['inProgress', 'upgraded', 'done']); + }); + + act(() => { + result.current.clearFilters(); + }); + + expect(result.current.filters).toEqual([]); + }); + + it('should clear filters when already empty', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.clearFilters(); + }); + + expect(result.current.filters).toEqual([]); + }); + + it('should not affect sortBy and pageNumber when clearing filters', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['inProgress']); + result.current.setSortBy('title'); + result.current.setPageNumber(3); + }); + + act(() => { + result.current.clearFilters(); + }); + + expect(result.current.filters).toEqual([]); + expect(result.current.sortBy).toBe('title'); + expect(result.current.pageNumber).toBe(3); + }); + }); + }); + + describe('sortBy management', () => { + it('should have initial sortBy as "enrolled"', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + expect(result.current.sortBy).toBe('enrolled'); + }); + + it('should set sortBy to "title"', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSortBy('title'); + }); + + expect(result.current.sortBy).toBe('title'); + }); + + it('should set sortBy to "enrolled"', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSortBy('title'); + }); + + act(() => { + result.current.setSortBy('enrolled'); + }); + + expect(result.current.sortBy).toBe('enrolled'); + }); + + it('should change sortBy options multiple times', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const sortSequence = ['title', 'enrolled', 'title', 'enrolled'] as const; + + sortSequence.forEach(sortOption => { + act(() => { + result.current.setSortBy(sortOption); + }); + expect(result.current.sortBy).toBe(sortOption); + }); + }); + }); + + describe('pageNumber management', () => { + it('should have initial pageNumber as 1', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + expect(result.current.pageNumber).toBe(1); + }); + + it('should set pageNumber correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setPageNumber(5); + }); + + expect(result.current.pageNumber).toBe(5); + }); + + it('should handle zero and negative page numbers', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setPageNumber(0); + }); + expect(result.current.pageNumber).toBe(0); + + act(() => { + result.current.setPageNumber(-1); + }); + expect(result.current.pageNumber).toBe(-1); + }); + + it('should handle large page numbers', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setPageNumber(999999); + }); + + expect(result.current.pageNumber).toBe(999999); + }); + + it('should change pageNumber multiple times', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const pageSequence = [2, 10, 1, 5, 3]; + + pageSequence.forEach(page => { + act(() => { + result.current.setPageNumber(page); + }); + expect(result.current.pageNumber).toBe(page); + }); + }); + }); + + describe('reducer functionality', () => { + it('should handle SET_FILTERS action correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const testFilters = ['filter1', 'filter2']; + + act(() => { + result.current.setFilters(testFilters); + }); + + expect(result.current.filters).toEqual(testFilters); + }); + + it('should handle ADD_FILTER action correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.addFilter('testFilter'); + }); + + expect(result.current.filters).toEqual(['testFilter']); + }); + + it('should handle REMOVE_FILTER action correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['filter1', 'filter2', 'filter3']); + }); + + act(() => { + result.current.removeFilter('filter2'); + }); + + expect(result.current.filters).toEqual(['filter1', 'filter3']); + }); + + it('should handle CLEAR_FILTERS action correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setFilters(['filter1', 'filter2']); + }); + + act(() => { + result.current.clearFilters(); + }); + + expect(result.current.filters).toEqual([]); + }); + + it('should handle SET_SORT_BY action correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSortBy('title'); + }); + + expect(result.current.sortBy).toBe('title'); + }); + + it('should handle SET_PAGE_NUMBER action correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setPageNumber(10); + }); + + expect(result.current.pageNumber).toBe(10); + }); + }); + + describe('integration scenarios', () => { + it('should handle complete state management workflow', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + expect(result.current.filters).toEqual([]); + expect(result.current.sortBy).toBe('enrolled'); + expect(result.current.pageNumber).toBe(1); + act(() => { + result.current.addFilter('inProgress'); + result.current.addFilter('upgraded'); + }); + expect(result.current.filters).toEqual(['inProgress', 'upgraded']); + act(() => { + result.current.setSortBy('title'); + result.current.setPageNumber(2); + }); + expect(result.current.sortBy).toBe('title'); + expect(result.current.pageNumber).toBe(2); + act(() => { + result.current.removeFilter('inProgress'); + }); + expect(result.current.filters).toEqual(['upgraded']); + act(() => { + result.current.setFilters(['done', 'notEnrolled']); + }); + expect(result.current.filters).toEqual(['done', 'notEnrolled']); + act(() => { + result.current.clearFilters(); + }); + expect(result.current.filters).toEqual([]); + expect(result.current.sortBy).toBe('title'); + expect(result.current.pageNumber).toBe(2); + }); + + it('should handle realistic course filtering workflow', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + expect(result.current.pageNumber).toBe(1); + act(() => { + result.current.addFilter('inProgress'); + }); + expect(result.current.filters).toContain('inProgress'); + act(() => { + result.current.addFilter('upgraded'); + }); + expect(result.current.filters).toEqual(['inProgress', 'upgraded']); + act(() => { + result.current.setSortBy('title'); + }); + expect(result.current.sortBy).toBe('title'); + act(() => { + result.current.setPageNumber(2); + }); + expect(result.current.pageNumber).toBe(2); + act(() => { + result.current.removeFilter('inProgress'); + }); + expect(result.current.filters).toEqual(['upgraded']); + act(() => { + result.current.clearFilters(); + }); + expect(result.current.filters).toEqual([]); + expect(result.current.sortBy).toBe('title'); + expect(result.current.pageNumber).toBe(2); + }); + + it('should handle multiple providers independently', () => { + const wrapper1 = createWrapper(); + const wrapper2 = createWrapper(); + + const { result: result1 } = renderHook(() => useFilters(), { wrapper: wrapper1 }); + const { result: result2 } = renderHook(() => useFilters(), { wrapper: wrapper2 }); + act(() => { + result1.current.addFilter('filter1'); + result1.current.setSortBy('title'); + result1.current.setPageNumber(3); + }); + + act(() => { + result2.current.addFilter('filter2'); + result2.current.setSortBy('enrolled'); + result2.current.setPageNumber(5); + }); + expect(result1.current.filters).toEqual(['filter1']); + expect(result1.current.sortBy).toBe('title'); + expect(result1.current.pageNumber).toBe(3); + + expect(result2.current.filters).toEqual(['filter2']); + expect(result2.current.sortBy).toBe('enrolled'); + expect(result2.current.pageNumber).toBe(5); + }); + }); + + describe('provider functionality', () => { + it('should provide context value correctly', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeDefined(); + expect(result.current.filters).toBeDefined(); + expect(result.current.sortBy).toBeDefined(); + expect(result.current.pageNumber).toBeDefined(); + expect(typeof result.current.setFilters).toBe('function'); + expect(typeof result.current.addFilter).toBe('function'); + expect(typeof result.current.removeFilter).toBe('function'); + expect(typeof result.current.clearFilters).toBe('function'); + expect(typeof result.current.setSortBy).toBe('function'); + expect(typeof result.current.setPageNumber).toBe('function'); + }); + + it('should handle provider re-renders without losing state', () => { + const TestWrapper = ({ rerenderTrigger, children }: { rerenderTrigger: number, children: ReactNode }) => ( + +
+ {children} +
+
+ ); + + const { result, rerender } = renderHook(() => useFilters(), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.setFilters(['persistentFilter']); + result.current.setSortBy('title'); + result.current.setPageNumber(5); + }); + + rerender({ rerenderTrigger: 2 }); + expect(result.current.filters).toEqual(['persistentFilter']); + expect(result.current.sortBy).toBe('title'); + expect(result.current.pageNumber).toBe(5); + }); + + it('should maintain function referential stability', () => { + const { result, rerender } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const initialFunctions = { + setFilters: result.current.setFilters, + addFilter: result.current.addFilter, + removeFilter: result.current.removeFilter, + clearFilters: result.current.clearFilters, + setSortBy: result.current.setSortBy, + setPageNumber: result.current.setPageNumber, + }; + rerender(); + + expect(result.current.setFilters).toBe(initialFunctions.setFilters); + expect(result.current.addFilter).toBe(initialFunctions.addFilter); + expect(result.current.removeFilter).toBe(initialFunctions.removeFilter); + expect(result.current.clearFilters).toBe(initialFunctions.clearFilters); + expect(result.current.setSortBy).toBe(initialFunctions.setSortBy); + expect(result.current.setPageNumber).toBe(initialFunctions.setPageNumber); + }); + }); + + describe('memoization behavior', () => { + it('should memoize context value when state does not change', () => { + const { result, rerender } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const firstContextValue = result.current; + rerender(); + expect(result.current.setFilters).toBe(firstContextValue.setFilters); + expect(result.current.addFilter).toBe(firstContextValue.addFilter); + expect(result.current.removeFilter).toBe(firstContextValue.removeFilter); + expect(result.current.clearFilters).toBe(firstContextValue.clearFilters); + expect(result.current.setSortBy).toBe(firstContextValue.setSortBy); + expect(result.current.setPageNumber).toBe(firstContextValue.setPageNumber); + }); + + it('should update memoized value when state changes', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const initialValue = result.current; + + act(() => { + result.current.addFilter('newFilter'); + }); + expect(result.current.filters).not.toEqual(initialValue.filters); + expect(result.current.filters).toContain('newFilter'); + }); + }); + + describe('edge cases and type safety', () => { + it('should handle empty string filters', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.addFilter(''); + }); + + expect(result.current.filters).toEqual(['']); + }); + + it('should handle special character filters', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + + const specialFilters = ['filter-with-dash', 'filter_with_underscore', 'filter with space', 'filter!@#$%']; + + specialFilters.forEach(filter => { + act(() => { + result.current.addFilter(filter); + }); + }); + + expect(result.current.filters).toEqual(specialFilters); + }); + + it('should maintain type safety for SortOption', () => { + const { result } = renderHook(() => useFilters(), { + wrapper: createWrapper(), + }); + const validSortOptions: ('enrolled' | 'title')[] = ['enrolled', 'title']; + + validSortOptions.forEach(option => { + act(() => { + result.current.setSortBy(option); + }); + expect(result.current.sortBy).toBe(option); + }); + }); + }); +}); diff --git a/src/data/context/FiltersProvider.tsx b/src/data/context/FiltersProvider.tsx new file mode 100644 index 000000000..f0502859b --- /dev/null +++ b/src/data/context/FiltersProvider.tsx @@ -0,0 +1,66 @@ +import React, { + createContext, useContext, useState, useMemo, useCallback, +} from 'react'; + +type SortOption = 'enrolled' | 'title'; + +interface FiltersContextType { + filters: string[], + sortBy: SortOption, + pageNumber: number, + setFilters: (newFilters: string[]) => void, + addFilter: (filter: string) => void, + removeFilter: (filter: string) => void, + clearFilters: () => void, + setSortBy: (sortBy: SortOption) => void, + setPageNumber: (pageNumber: number) => void, +} + +const FiltersContext = createContext(null); + +export const FiltersProvider = ({ children }: { children: React.ReactNode }) => { + const [filters, setFilters] = useState([]); + const [sortBy, setSortBy] = useState('enrolled'); + const [pageNumber, setPageNumber] = useState(1); + + const addFilter = useCallback((filter: string) => { + setFilters(prev => [...prev, filter]); + }, []); + + const removeFilter = useCallback((filter: string) => { + setFilters(prev => prev.filter(item => item !== filter)); + }, []); + + const clearFilters = useCallback(() => { + setFilters([]); + }, []); + + const contextValue = useMemo( + () => ({ + filters, + sortBy, + pageNumber, + setFilters, + addFilter, + removeFilter, + clearFilters, + setSortBy, + setPageNumber, + }), + [filters, sortBy, pageNumber, addFilter, removeFilter, clearFilters], + ); + + return ( + + {children} + + ); +}; + +export const useFilters = () => { + const context = useContext(FiltersContext); + if (!context) { + throw new Error('useFilters must be used within a FiltersProvider'); + } + return context; +}; diff --git a/src/data/context/Masquerade.test.tsx b/src/data/context/Masquerade.test.tsx new file mode 100644 index 000000000..1765c5e6e --- /dev/null +++ b/src/data/context/Masquerade.test.tsx @@ -0,0 +1,579 @@ +import type { ReactNode } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { MasqueradeProvider, useMasquerade } from './MasqueradeProvider'; + +describe('MasqueradeProvider and useMasquerade', () => { + const createWrapper = () => function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + + describe('useMasquerade hook', () => { + describe('initial state', () => { + it('should return initial masquerade state', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + expect(result.current.masqueradeUser).toBeUndefined(); + expect(typeof result.current.setMasqueradeUser).toBe('function'); + }); + + it('should have expected properties in context', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const expectedProperties = ['masqueradeUser', 'setMasqueradeUser']; + + expectedProperties.forEach(prop => { + expect(result.current).toHaveProperty(prop); + }); + }); + }); + + describe('error handling', () => { + it('should throw error when used outside of provider', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useMasquerade()); + }).toThrow('useMasquerade must be used within a MasqueradeProvider'); + + consoleErrorSpy.mockRestore(); + }); + + it('should throw error with correct message when context is null', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + renderHook(() => useMasquerade()); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('useMasquerade must be used within a MasqueradeProvider'); + } + + consoleErrorSpy.mockRestore(); + }); + }); + }); + + describe('masquerade user management', () => { + describe('setMasqueradeUser', () => { + it('should set masquerade user to string value', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const testUser = 'test-user@example.com'; + + act(() => { + result.current.setMasqueradeUser(testUser); + }); + + expect(result.current.masqueradeUser).toBe(testUser); + }); + + it('should set masquerade user to undefined', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setMasqueradeUser('initial-user'); + }); + + act(() => { + result.current.setMasqueradeUser(undefined); + }); + + expect(result.current.masqueradeUser).toBeUndefined(); + }); + + it('should handle email format users', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const emailUser = 'student@university.edu'; + + act(() => { + result.current.setMasqueradeUser(emailUser); + }); + + expect(result.current.masqueradeUser).toBe(emailUser); + }); + + it('should handle username format users', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const usernameUser = 'student123'; + + act(() => { + result.current.setMasqueradeUser(usernameUser); + }); + + expect(result.current.masqueradeUser).toBe(usernameUser); + }); + + it('should handle empty string users', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setMasqueradeUser(''); + }); + + expect(result.current.masqueradeUser).toBe(''); + }); + + it('should handle users with special characters', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const specialUser = 'user.name+tag@domain-name.co.uk'; + + act(() => { + result.current.setMasqueradeUser(specialUser); + }); + + expect(result.current.masqueradeUser).toBe(specialUser); + }); + + it('should replace existing masquerade user', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setMasqueradeUser('first-user'); + }); + + act(() => { + result.current.setMasqueradeUser('second-user'); + }); + + expect(result.current.masqueradeUser).toBe('second-user'); + }); + + it('should handle multiple sequential updates', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const users = ['user1@test.com', 'user2@test.com', 'user3@test.com']; + + users.forEach(user => { + act(() => { + result.current.setMasqueradeUser(user); + }); + expect(result.current.masqueradeUser).toBe(user); + }); + }); + + it('should handle unicode characters in usernames', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const unicodeUser = 'José.García@université.fr'; + + act(() => { + result.current.setMasqueradeUser(unicodeUser); + }); + + expect(result.current.masqueradeUser).toBe(unicodeUser); + }); + + it('should handle very long usernames', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const longUser = `${'a'.repeat(100)}@example.com`; + + act(() => { + result.current.setMasqueradeUser(longUser); + }); + + expect(result.current.masqueradeUser).toBe(longUser); + }); + }); + + describe('masquerade workflow scenarios', () => { + it('should handle start masquerade workflow', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + expect(result.current.masqueradeUser).toBeUndefined(); + + act(() => { + result.current.setMasqueradeUser('target-student@university.edu'); + }); + + expect(result.current.masqueradeUser).toBe('target-student@university.edu'); + }); + + it('should handle stop masquerade workflow', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setMasqueradeUser('masqueraded-user'); + }); + + act(() => { + result.current.setMasqueradeUser(undefined); + }); + + expect(result.current.masqueradeUser).toBeUndefined(); + }); + + it('should handle switch masquerade user workflow', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setMasqueradeUser('first-student'); + }); + expect(result.current.masqueradeUser).toBe('first-student'); + + act(() => { + result.current.setMasqueradeUser('second-student'); + }); + expect(result.current.masqueradeUser).toBe('second-student'); + + act(() => { + result.current.setMasqueradeUser(undefined); + }); + expect(result.current.masqueradeUser).toBeUndefined(); + }); + + it('should handle admin masquerade session', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + const user = 'user123@institution.edu'; + + act(() => { + result.current.setMasqueradeUser(user); + }); + expect(result.current.masqueradeUser).toBe(user); + + act(() => { + result.current.setMasqueradeUser(undefined); + }); + expect(result.current.masqueradeUser).toBeUndefined(); + }); + }); + }); + + describe('reducer functionality', () => { + it('should handle SET_MASQUERADE_USER action with string payload', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setMasqueradeUser('test-user'); + }); + + expect(result.current.masqueradeUser).toBe('test-user'); + }); + + it('should handle SET_MASQUERADE_USER action with undefined payload', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setMasqueradeUser('user'); + }); + + act(() => { + result.current.setMasqueradeUser(undefined); + }); + + expect(result.current.masqueradeUser).toBeUndefined(); + }); + + it('should maintain state immutability', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setMasqueradeUser('immutable-test'); + }); + + const currentUser = result.current.masqueradeUser; + + act(() => { + result.current.setMasqueradeUser('new-user'); + }); + + expect(currentUser).toBe('immutable-test'); + expect(result.current.masqueradeUser).toBe('new-user'); + }); + + it('should handle rapid successive updates', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const updates = ['user1', 'user2', 'user3', undefined, 'user4']; + + act(() => { + updates.forEach(user => { + result.current.setMasqueradeUser(user); + }); + }); + + expect(result.current.masqueradeUser).toBe('user4'); + }); + }); + + describe('provider functionality', () => { + it('should provide context value correctly', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeDefined(); + expect(result.current.masqueradeUser).toBeUndefined(); + expect(typeof result.current.setMasqueradeUser).toBe('function'); + }); + + it('should handle provider re-renders without losing state', () => { + const TestWrapper = ({ rerenderTrigger, children }: { rerenderTrigger: number, children: ReactNode }) => ( + +
+ {children} +
+
+ ); + + const { result, rerender } = renderHook(() => useMasquerade(), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.setMasqueradeUser('persistent-user'); + }); + + rerender({ rerenderTrigger: 2 }); + + expect(result.current.masqueradeUser).toBe('persistent-user'); + }); + + it('should maintain function referential stability', () => { + const { result, rerender } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const initialSetFunction = result.current.setMasqueradeUser; + + rerender(); + + expect(result.current.setMasqueradeUser).toBe(initialSetFunction); + }); + + it('should handle multiple providers independently', () => { + const wrapper1 = createWrapper(); + const wrapper2 = createWrapper(); + + const { result: result1 } = renderHook(() => useMasquerade(), { wrapper: wrapper1 }); + const { result: result2 } = renderHook(() => useMasquerade(), { wrapper: wrapper2 }); + + act(() => { + result1.current.setMasqueradeUser('user1'); + }); + + act(() => { + result2.current.setMasqueradeUser('user2'); + }); + + expect(result1.current.masqueradeUser).toBe('user1'); + expect(result2.current.masqueradeUser).toBe('user2'); + }); + }); + + describe('memoization behavior', () => { + it('should memoize context value when state does not change', () => { + const { result, rerender } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const firstSetFunction = result.current.setMasqueradeUser; + + rerender(); + + expect(result.current.setMasqueradeUser).toBe(firstSetFunction); + }); + + it('should update memoized value when state changes', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const initialUser = result.current.masqueradeUser; + + act(() => { + result.current.setMasqueradeUser('changed-user'); + }); + + expect(result.current.masqueradeUser).not.toBe(initialUser); + expect(result.current.masqueradeUser).toBe('changed-user'); + }); + }); + + describe('integration scenarios', () => { + it('should handle realistic masquerade admin workflow', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + expect(result.current.masqueradeUser).toBeUndefined(); + + act(() => { + result.current.setMasqueradeUser('john.doe@university.edu'); + }); + expect(result.current.masqueradeUser).toBe('john.doe@university.edu'); + + act(() => { + result.current.setMasqueradeUser('jane.smith@university.edu'); + }); + expect(result.current.masqueradeUser).toBe('jane.smith@university.edu'); + + act(() => { + result.current.setMasqueradeUser(undefined); + }); + expect(result.current.masqueradeUser).toBeUndefined(); + }); + + it('should handle development testing workflow', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const testUsers = [ + 'test-student-1@test.edu', + 'test-instructor@test.edu', + 'test-admin@test.edu', + ]; + + testUsers.forEach(testUser => { + act(() => { + result.current.setMasqueradeUser(testUser); + }); + expect(result.current.masqueradeUser).toBe(testUser); + + act(() => { + result.current.setMasqueradeUser(undefined); + }); + expect(result.current.masqueradeUser).toBeUndefined(); + }); + }); + + it('should handle concurrent masquerade sessions in different contexts', () => { + const wrapper1 = createWrapper(); + const wrapper2 = createWrapper(); + + const { result: admin1 } = renderHook(() => useMasquerade(), { wrapper: wrapper1 }); + const { result: admin2 } = renderHook(() => useMasquerade(), { wrapper: wrapper2 }); + + act(() => { + admin1.current.setMasqueradeUser('student1@university.edu'); + admin2.current.setMasqueradeUser('student2@university.edu'); + }); + + expect(admin1.current.masqueradeUser).toBe('student1@university.edu'); + expect(admin2.current.masqueradeUser).toBe('student2@university.edu'); + + act(() => { + admin1.current.setMasqueradeUser(undefined); + }); + + expect(admin1.current.masqueradeUser).toBeUndefined(); + expect(admin2.current.masqueradeUser).toBe('student2@university.edu'); + }); + }); + + describe('type safety and edge cases', () => { + it('should handle type-safe user parameter', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const stringUser = 'typed-user@test.com'; + const undefinedUser = undefined; + + act(() => { + result.current.setMasqueradeUser(stringUser); + }); + expect(result.current.masqueradeUser).toBe(stringUser); + + act(() => { + result.current.setMasqueradeUser(undefinedUser); + }); + expect(result.current.masqueradeUser).toBe(undefinedUser); + }); + + it('should handle whitespace-only usernames', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const whitespaceUser = ' '; + + act(() => { + result.current.setMasqueradeUser(whitespaceUser); + }); + + expect(result.current.masqueradeUser).toBe(whitespaceUser); + }); + + it('should handle tab and newline characters', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const specialCharsUser = 'user\t\nwith\rspecial@chars.com'; + + act(() => { + result.current.setMasqueradeUser(specialCharsUser); + }); + + expect(result.current.masqueradeUser).toBe(specialCharsUser); + }); + + it('should maintain state consistency across multiple updates', () => { + const { result } = renderHook(() => useMasquerade(), { + wrapper: createWrapper(), + }); + + const updates = [ + 'user1', + undefined, + 'user2', + '', + 'user3', + undefined, + 'final-user', + ]; + + updates.forEach(update => { + act(() => { + result.current.setMasqueradeUser(update); + }); + expect(result.current.masqueradeUser).toBe(update); + }); + }); + }); +}); diff --git a/src/data/context/MasqueradeProvider.tsx b/src/data/context/MasqueradeProvider.tsx new file mode 100644 index 000000000..f424ef35c --- /dev/null +++ b/src/data/context/MasqueradeProvider.tsx @@ -0,0 +1,37 @@ +import React, { + createContext, useContext, useMemo, useState, ReactNode, +} from 'react'; + +interface MasqueradeContextType { + masqueradeUser: string | undefined, + setMasqueradeUser: (user: string | undefined) => void, +} + +const MasqueradeContext = createContext(null); + +interface MasqueradeProviderProps { + children: ReactNode, +} + +export const MasqueradeProvider: React.FC = ({ children }) => { + const [masqueradeUser, setMasqueradeUser] = useState(undefined); + + const contextValue = useMemo(() => ({ + masqueradeUser, + setMasqueradeUser, + }), [masqueradeUser]); + + return ( + + {children} + + ); +}; + +export const useMasquerade = (): MasqueradeContextType => { + const context = useContext(MasqueradeContext); + if (!context) { + throw new Error('useMasquerade must be used within a MasqueradeProvider'); + } + return context; +}; diff --git a/src/data/context/SelectSession.test.tsx b/src/data/context/SelectSession.test.tsx new file mode 100644 index 000000000..ccd85ab44 --- /dev/null +++ b/src/data/context/SelectSession.test.tsx @@ -0,0 +1,663 @@ +import type { ReactNode } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { SelectSessionModalProvider, useSelectSessionModal } from './SelectSessionProvider'; + +describe('SelectSessionModalProvider and useSelectSessionModal', () => { + const createWrapper = () => function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + + describe('useSelectSessionModal hook', () => { + describe('initial state', () => { + it('should return initial select session modal state', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + expect(result.current.selectSessionModal.cardId).toBeNull(); + expect(typeof result.current.updateSelectSessionModal).toBe('function'); + expect(typeof result.current.closeSelectSessionModal).toBe('function'); + }); + + it('should have all expected properties in context', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const expectedProperties = [ + 'selectSessionModal', + 'updateSelectSessionModal', + 'closeSelectSessionModal', + ]; + + expectedProperties.forEach(prop => { + expect(result.current).toHaveProperty(prop); + }); + }); + + it('should have selectSessionModal with cardId property', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + expect(result.current.selectSessionModal).toHaveProperty('cardId'); + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + }); + + describe('error handling', () => { + it('should throw error when used outside of provider', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useSelectSessionModal()); + }).toThrow('useSelectSessionModal must be used within a SelectSessionModalProvider'); + + consoleErrorSpy.mockRestore(); + }); + + it('should throw error with correct message when context is null', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + renderHook(() => useSelectSessionModal()); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('useSelectSessionModal must be used within a SelectSessionModalProvider'); + } + + consoleErrorSpy.mockRestore(); + }); + }); + }); + + describe('modal state management', () => { + describe('updateSelectSessionModal', () => { + it('should set cardId to string value', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const testCardId = 'card-123'; + + act(() => { + result.current.updateSelectSessionModal(testCardId); + }); + + expect(result.current.selectSessionModal.cardId).toBe(testCardId); + }); + + it('should set cardId to null', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.updateSelectSessionModal('card-123'); + }); + + act(() => { + result.current.updateSelectSessionModal(null); + }); + + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + + it('should handle various cardId formats', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const cardIds = [ + 'card-1', + 'card-abc-123', + 'course-card-uuid-456', + 'simple-id', + 'id_with_underscores', + 'id-with-dashes', + '12345', + 'a', + ]; + + cardIds.forEach(cardId => { + act(() => { + result.current.updateSelectSessionModal(cardId); + }); + expect(result.current.selectSessionModal.cardId).toBe(cardId); + }); + }); + + it('should handle empty string cardId', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.updateSelectSessionModal(''); + }); + + expect(result.current.selectSessionModal.cardId).toBe(''); + }); + + it('should handle long cardId strings', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const longCardId = `card-${'a'.repeat(100)}`; + + act(() => { + result.current.updateSelectSessionModal(longCardId); + }); + + expect(result.current.selectSessionModal.cardId).toBe(longCardId); + }); + + it('should handle special characters in cardId', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const specialCardId = 'card@123#$%^&*()'; + + act(() => { + result.current.updateSelectSessionModal(specialCardId); + }); + + expect(result.current.selectSessionModal.cardId).toBe(specialCardId); + }); + + it('should handle unicode characters in cardId', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const unicodeCardId = 'card-节点-123'; + + act(() => { + result.current.updateSelectSessionModal(unicodeCardId); + }); + + expect(result.current.selectSessionModal.cardId).toBe(unicodeCardId); + }); + + it('should replace existing cardId', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.updateSelectSessionModal('first-card'); + }); + + act(() => { + result.current.updateSelectSessionModal('second-card'); + }); + + expect(result.current.selectSessionModal.cardId).toBe('second-card'); + }); + + it('should handle multiple sequential updates', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const cardIds = ['card-1', 'card-2', 'card-3', null, 'card-4']; + + cardIds.forEach(cardId => { + act(() => { + result.current.updateSelectSessionModal(cardId); + }); + expect(result.current.selectSessionModal.cardId).toBe(cardId); + }); + }); + }); + + describe('closeSelectSessionModal', () => { + it('should set cardId to null when modal is closed', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.updateSelectSessionModal('card-123'); + }); + + act(() => { + result.current.closeSelectSessionModal(); + }); + + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + + it('should close modal when cardId is already null', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.closeSelectSessionModal(); + }); + + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + + it('should close modal multiple times without error', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.closeSelectSessionModal(); + result.current.closeSelectSessionModal(); + result.current.closeSelectSessionModal(); + }); + + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + + it('should work correctly after multiple open and close cycles', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const cycles = [ + 'card-1', + 'card-2', + 'card-3', + ]; + + cycles.forEach(cardId => { + act(() => { + result.current.updateSelectSessionModal(cardId); + }); + expect(result.current.selectSessionModal.cardId).toBe(cardId); + + act(() => { + result.current.closeSelectSessionModal(); + }); + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + }); + }); + }); + + describe('reducer functionality', () => { + it('should handle UPDATE_SELECT_SESSION_MODAL action correctly', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.updateSelectSessionModal('test-card'); + }); + + expect(result.current.selectSessionModal.cardId).toBe('test-card'); + }); + + it('should handle CLOSE_SELECT_SESSION_MODAL action correctly', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.updateSelectSessionModal('test-card'); + }); + + act(() => { + result.current.closeSelectSessionModal(); + }); + + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + + it('should maintain state immutability', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.updateSelectSessionModal('immutable-test'); + }); + + const currentState = result.current.selectSessionModal; + + act(() => { + result.current.updateSelectSessionModal('new-card'); + }); + + expect(currentState.cardId).toBe('immutable-test'); + expect(result.current.selectSessionModal.cardId).toBe('new-card'); + expect(result.current.selectSessionModal).not.toBe(currentState); + }); + + it('should handle rapid successive updates', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const updates = ['card1', 'card2', 'card3', null, 'card4']; + + act(() => { + updates.forEach(cardId => { + result.current.updateSelectSessionModal(cardId); + }); + }); + + expect(result.current.selectSessionModal.cardId).toBe('card4'); + }); + + it('should create new state object on each update', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const initialState = result.current.selectSessionModal; + + act(() => { + result.current.updateSelectSessionModal('new-card'); + }); + + const updatedState = result.current.selectSessionModal; + + expect(updatedState).not.toBe(initialState); + expect(updatedState.cardId).not.toBe(initialState.cardId); + }); + }); + + describe('provider functionality', () => { + it('should provide context value correctly', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeDefined(); + expect(result.current.selectSessionModal).toBeDefined(); + expect(result.current.selectSessionModal.cardId).toBeNull(); + expect(typeof result.current.updateSelectSessionModal).toBe('function'); + expect(typeof result.current.closeSelectSessionModal).toBe('function'); + }); + + it('should handle provider re-renders without losing state', () => { + const TestWrapper = ({ rerenderTrigger, children }: { rerenderTrigger: number, children: ReactNode }) => ( + +
+ {children} +
+
+ ); + + const { result, rerender } = renderHook(() => useSelectSessionModal(), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.updateSelectSessionModal('persistent-card'); + }); + + rerender({ rerenderTrigger: 2 }); + + expect(result.current.selectSessionModal.cardId).toBe('persistent-card'); + }); + + it('should maintain function referential stability', () => { + const { result, rerender } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const initialFunctions = { + updateSelectSessionModal: result.current.updateSelectSessionModal, + closeSelectSessionModal: result.current.closeSelectSessionModal, + }; + + rerender(); + + expect(result.current.updateSelectSessionModal).toBe(initialFunctions.updateSelectSessionModal); + expect(result.current.closeSelectSessionModal).toBe(initialFunctions.closeSelectSessionModal); + }); + + it('should handle multiple providers independently', () => { + const wrapper1 = createWrapper(); + const wrapper2 = createWrapper(); + + const { result: result1 } = renderHook(() => useSelectSessionModal(), { wrapper: wrapper1 }); + const { result: result2 } = renderHook(() => useSelectSessionModal(), { wrapper: wrapper2 }); + + act(() => { + result1.current.updateSelectSessionModal('card1'); + }); + + act(() => { + result2.current.updateSelectSessionModal('card2'); + }); + + expect(result1.current.selectSessionModal.cardId).toBe('card1'); + expect(result2.current.selectSessionModal.cardId).toBe('card2'); + }); + }); + + describe('memoization behavior', () => { + it('should memoize context value when state does not change', () => { + const { result, rerender } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const initialFunctions = { + updateSelectSessionModal: result.current.updateSelectSessionModal, + closeSelectSessionModal: result.current.closeSelectSessionModal, + }; + + rerender(); + + expect(result.current.updateSelectSessionModal).toBe(initialFunctions.updateSelectSessionModal); + expect(result.current.closeSelectSessionModal).toBe(initialFunctions.closeSelectSessionModal); + }); + + it('should update memoized value when selectSessionModal state changes', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const initialCardId = result.current.selectSessionModal.cardId; + + act(() => { + result.current.updateSelectSessionModal('changed-card'); + }); + + expect(result.current.selectSessionModal.cardId).not.toBe(initialCardId); + expect(result.current.selectSessionModal.cardId).toBe('changed-card'); + }); + }); + + describe('integration scenarios', () => { + it('should handle realistic modal workflow', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + expect(result.current.selectSessionModal.cardId).toBeNull(); + + act(() => { + result.current.updateSelectSessionModal('course-card-123'); + }); + expect(result.current.selectSessionModal.cardId).toBe('course-card-123'); + + act(() => { + result.current.closeSelectSessionModal(); + }); + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + + it('should handle session selection workflow for different courses', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const courseCards = [ + 'course-cs101-card', + 'course-math201-card', + 'course-physics301-card', + ]; + + courseCards.forEach(cardId => { + act(() => { + result.current.updateSelectSessionModal(cardId); + }); + expect(result.current.selectSessionModal.cardId).toBe(cardId); + + act(() => { + result.current.closeSelectSessionModal(); + }); + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + }); + + it('should handle switching between different cards without closing', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const cardSequence = [ + 'card-A', + 'card-B', + 'card-C', + 'card-D', + ]; + + cardSequence.forEach(cardId => { + act(() => { + result.current.updateSelectSessionModal(cardId); + }); + expect(result.current.selectSessionModal.cardId).toBe(cardId); + }); + + act(() => { + result.current.closeSelectSessionModal(); + }); + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + + it('should handle entitlement selection modal workflow', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const entitlementCard = 'entitlement-card-uuid-456'; + + act(() => { + result.current.updateSelectSessionModal(entitlementCard); + }); + expect(result.current.selectSessionModal.cardId).toBe(entitlementCard); + + act(() => { + result.current.updateSelectSessionModal(null); + }); + expect(result.current.selectSessionModal.cardId).toBeNull(); + + act(() => { + result.current.updateSelectSessionModal(entitlementCard); + }); + expect(result.current.selectSessionModal.cardId).toBe(entitlementCard); + + act(() => { + result.current.closeSelectSessionModal(); + }); + expect(result.current.selectSessionModal.cardId).toBeNull(); + }); + + it('should handle concurrent modal operations in different contexts', () => { + const wrapper1 = createWrapper(); + const wrapper2 = createWrapper(); + + const { result: result1 } = renderHook(() => useSelectSessionModal(), { wrapper: wrapper1 }); + const { result: result2 } = renderHook(() => useSelectSessionModal(), { wrapper: wrapper2 }); + + act(() => { + result1.current.updateSelectSessionModal('modal-1-card'); + result2.current.updateSelectSessionModal('modal-2-card'); + }); + + expect(result1.current.selectSessionModal.cardId).toBe('modal-1-card'); + expect(result2.current.selectSessionModal.cardId).toBe('modal-2-card'); + + act(() => { + result1.current.closeSelectSessionModal(); + }); + + expect(result1.current.selectSessionModal.cardId).toBeNull(); + expect(result2.current.selectSessionModal.cardId).toBe('modal-2-card'); + }); + }); + + describe('type safety and edge cases', () => { + it('should handle type-safe cardId parameter', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const stringCardId = 'typed-card-123'; + const nullCardId = null; + + act(() => { + result.current.updateSelectSessionModal(stringCardId); + }); + expect(result.current.selectSessionModal.cardId).toBe(stringCardId); + + act(() => { + result.current.updateSelectSessionModal(nullCardId); + }); + expect(result.current.selectSessionModal.cardId).toBe(nullCardId); + }); + + it('should maintain state consistency across multiple updates', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const updates = [ + 'card-1', + null, + 'card-2', + '', + 'card-3', + null, + 'final-card', + ]; + + updates.forEach(update => { + act(() => { + result.current.updateSelectSessionModal(update); + }); + expect(result.current.selectSessionModal.cardId).toBe(update); + }); + }); + + it('should handle whitespace-only cardIds', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const whitespaceCardId = ' '; + + act(() => { + result.current.updateSelectSessionModal(whitespaceCardId); + }); + + expect(result.current.selectSessionModal.cardId).toBe(whitespaceCardId); + }); + + it('should handle newlines and tabs in cardIds', () => { + const { result } = renderHook(() => useSelectSessionModal(), { + wrapper: createWrapper(), + }); + + const specialCharsCardId = 'card\t\nwith\rspecial-chars'; + + act(() => { + result.current.updateSelectSessionModal(specialCharsCardId); + }); + + expect(result.current.selectSessionModal.cardId).toBe(specialCharsCardId); + }); + }); +}); diff --git a/src/data/context/SelectSessionProvider.tsx b/src/data/context/SelectSessionProvider.tsx new file mode 100644 index 000000000..02b47bec7 --- /dev/null +++ b/src/data/context/SelectSessionProvider.tsx @@ -0,0 +1,85 @@ +import React, { + createContext, useContext, useReducer, useMemo, ReactNode, + useCallback, +} from 'react'; + +interface SelectSessionModalState { + cardId: string | null, +} + +interface SelectSessionModalContextType { + selectSessionModal: SelectSessionModalState, + updateSelectSessionModal: (cardId: string | null) => void, + closeSelectSessionModal: () => void, +} + +const SelectSessionModalContext = createContext(null); + +interface State { + selectSessionModal: SelectSessionModalState, +} + +type Action = + | { type: 'UPDATE_SELECT_SESSION_MODAL', payload: string | null } + | { type: 'CLOSE_SELECT_SESSION_MODAL' }; + +const initialState: State = { + selectSessionModal: { cardId: null }, +}; + +const selectSessionModalReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'UPDATE_SELECT_SESSION_MODAL': + return { + ...state, + selectSessionModal: { cardId: action.payload }, + }; + case 'CLOSE_SELECT_SESSION_MODAL': + return { + ...state, + selectSessionModal: { cardId: null }, + }; + /* istanbul ignore next */ + default: + return state; + } +}; + +interface SelectSessionModalProviderProps { + children: ReactNode, +} + +export const SelectSessionModalProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(selectSessionModalReducer, initialState); + + const updateSelectSessionModal = useCallback((cardId: string | null) => { + dispatch({ type: 'UPDATE_SELECT_SESSION_MODAL', payload: cardId }); + }, []); + + const closeSelectSessionModal = useCallback(() => { + dispatch({ type: 'CLOSE_SELECT_SESSION_MODAL' }); + }, []); + + const contextValue = useMemo( + () => ({ + selectSessionModal: state.selectSessionModal, + updateSelectSessionModal, + closeSelectSessionModal, + }), + [closeSelectSessionModal, state.selectSessionModal, updateSelectSessionModal], + ); + + return ( + + {children} + + ); +}; + +export const useSelectSessionModal = (): SelectSessionModalContextType => { + const context = useContext(SelectSessionModalContext); + if (!context) { + throw new Error('useSelectSessionModal must be used within a SelectSessionModalProvider'); + } + return context; +}; diff --git a/src/data/context/index.test.tsx b/src/data/context/index.test.tsx new file mode 100644 index 000000000..b869d37de --- /dev/null +++ b/src/data/context/index.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import ContextProviders from './index'; +import { useFilters } from './FiltersProvider'; +import { useSelectSessionModal } from './SelectSessionProvider'; + +const TestComponent = () => { + const filters = useFilters(); + const selectSessionModal = useSelectSessionModal(); + + return ( +
+
{filters ? 'Filters Available' : 'Filters Not Available'}
+
{selectSessionModal ? 'SelectSession Available' : 'SelectSession Not Available'}
+
+ ); +}; + +describe('ContextProviders', () => { + it('should render children', () => { + render( + +
Test Child
+
, + ); + + expect(screen.getByText('Test Child')).toBeInTheDocument(); + }); + + it('should provide all context providers to children', () => { + render( + + + , + ); + + expect(screen.getByText('Filters Available')).toBeInTheDocument(); + expect(screen.getByText('SelectSession Available')).toBeInTheDocument(); + }); + + it('should render multiple children', () => { + render( + +
First Child
+
Second Child
+
Third Child
+
, + ); + + expect(screen.getByText('First Child')).toBeInTheDocument(); + expect(screen.getByText('Second Child')).toBeInTheDocument(); + expect(screen.getByText('Third Child')).toBeInTheDocument(); + }); +}); diff --git a/src/data/context/index.tsx b/src/data/context/index.tsx new file mode 100644 index 000000000..cd5159b37 --- /dev/null +++ b/src/data/context/index.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; +import { MasqueradeProvider, useMasquerade } from './MasqueradeProvider'; +import { FiltersProvider, useFilters } from './FiltersProvider'; +import { SelectSessionModalProvider, useSelectSessionModal } from './SelectSessionProvider'; + +interface ContextProvidersProps { + children: ReactNode, +} + +const ContextProviders = ({ children }: ContextProvidersProps) => ( + + + {children} + + +); + +export { + MasqueradeProvider, useMasquerade, useFilters, useSelectSessionModal, +}; +export default ContextProviders; diff --git a/src/data/contexts/GlobalDataContext.jsx b/src/data/contexts/GlobalDataContext.jsx deleted file mode 100644 index 0aa5f39f4..000000000 --- a/src/data/contexts/GlobalDataContext.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext } from 'react'; - -const GlobalDataContext = createContext({ - emailConfirmation: { - isNeeded: false, - sendEmailUrl: '', - }, - platformSettings: { - courseSearchUrl: '', - }, - setEmailConfirmation: null, - setPlatformSettings: null, -}); - -export default GlobalDataContext; diff --git a/src/data/contexts/GlobalDataContext.tsx b/src/data/contexts/GlobalDataContext.tsx new file mode 100644 index 000000000..6ea6c5926 --- /dev/null +++ b/src/data/contexts/GlobalDataContext.tsx @@ -0,0 +1,31 @@ +import { createContext, Dispatch, SetStateAction } from 'react'; + +interface EmailConfirmation { + isNeeded: boolean, + sendEmailUrl: string, +} + +interface PlatformSettings { + courseSearchUrl: string, +} + +interface GlobalDataContextType { + emailConfirmation: EmailConfirmation, + platformSettings: PlatformSettings, + setEmailConfirmation: Dispatch> | null, + setPlatformSettings: Dispatch> | null, +} + +const GlobalDataContext = createContext({ + emailConfirmation: { + isNeeded: false, + sendEmailUrl: '', + }, + platformSettings: { + courseSearchUrl: '', + }, + setEmailConfirmation: null, + setPlatformSettings: null, +}); + +export default GlobalDataContext; diff --git a/src/data/contexts/MasqueradeUserContext.jsx b/src/data/contexts/MasqueradeUserContext.jsx deleted file mode 100644 index fbc74253e..000000000 --- a/src/data/contexts/MasqueradeUserContext.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext } from 'react'; - -const MasqueradeUserContext = createContext({ - masqueradeUser: undefined, - masqueradeIsSuccess: undefined, - masqueradeIsPending: undefined, - masqueradeIsError: undefined, - masqueradeError: undefined, - setMasqueradeUser: undefined, - setMasqueradeIsSuccess: undefined, - setMasqueradeIsPending: undefined, - setMasqueradeIsError: undefined, - setMasqueradeError: undefined, -}); - -export default MasqueradeUserContext; diff --git a/src/data/contexts/MasqueradeUserProvider.jsx b/src/data/contexts/MasqueradeUserProvider.jsx deleted file mode 100644 index 5609f150f..000000000 --- a/src/data/contexts/MasqueradeUserProvider.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useState, useMemo } from 'react'; - -import MasqueradeUserContext from './MasqueradeUserContext'; - -export default function MasqueradeUserProvider({ children }) { - const [masqueradeUser, setMasqueradeUser] = useState(null); - const [masqueradeIsSuccess, setMasqueradeIsSuccess] = useState(false); - const [masqueradeIsPending, setMasqueradeIsPending] = useState(false); - const [masqueradeIsError, setMasqueradeIsError] = useState(false); - const [masqueradeError, setMasqueradeError] = useState(null); - - const value = useMemo(() => ({ - masqueradeUser, - masqueradeIsPending, - masqueradeIsSuccess, - masqueradeIsError, - masqueradeError, - setMasqueradeUser, - setMasqueradeIsPending, - setMasqueradeIsSuccess, - setMasqueradeIsError, - setMasqueradeError, - }), [masqueradeUser, masqueradeIsSuccess, masqueradeIsPending, masqueradeIsError, masqueradeError]); - - return ( - - {children} - - ); -} - diff --git a/src/data/hooks/index.ts b/src/data/hooks/index.ts new file mode 100644 index 000000000..f9e06ec45 --- /dev/null +++ b/src/data/hooks/index.ts @@ -0,0 +1,19 @@ +import { useInitializeLearnerHome } from './queryHooks'; +import { + useUnenrollFromCourse, + useUpdateEntitlementEnrollment, + useDeleteEntitlementEnrollment, + useUpdateEmailSettings, + useCreateCreditRequest, + useSendConfirmEmail, +} from './mutationHooks'; + +export { + useInitializeLearnerHome, + useUnenrollFromCourse, + useUpdateEntitlementEnrollment, + useDeleteEntitlementEnrollment, + useUpdateEmailSettings, + useCreateCreditRequest, + useSendConfirmEmail, +}; diff --git a/src/data/hooks/mutationHooks.test.tsx b/src/data/hooks/mutationHooks.test.tsx new file mode 100644 index 000000000..ba18ffd99 --- /dev/null +++ b/src/data/hooks/mutationHooks.test.tsx @@ -0,0 +1,346 @@ +import { renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { logError } from '@openedx/frontend-base'; +import { + useUnenrollFromCourse, + useUpdateEntitlementEnrollment, + useDeleteEntitlementEnrollment, + useUpdateEmailSettings, + useCreateCreditRequest, + useSendConfirmEmail, +} from './mutationHooks'; +import * as api from '../services/lms/api'; + +// Mock external dependencies +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + logError: jest.fn(), +})); +jest.mock('@src/data/context'); +jest.mock('@src/data/services/lms/api'); + +const mockLogError = logError as jest.MockedFunction; + +// Create a test wrapper with QueryClient +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; +}; + +describe('mutationHooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useUnenrollFromCourse', () => { + it('should unenroll successfully and invalidate queries', async () => { + const queryClient = new QueryClient(); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + (api.unenrollFromCourse as jest.Mock).mockResolvedValue({}); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useUnenrollFromCourse(), { wrapper }); + + await result.current.mutateAsync({ courseId: 'test-course-id' }); + + expect(api.unenrollFromCourse).toHaveBeenCalledWith({ courseId: 'test-course-id' }); + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ + queryKey: expect.arrayContaining(['learner-dashboard', 'initialize']), + }); + }); + + it('should log error when unenroll fails', async () => { + const error = new Error('Network error'); + (api.unenrollFromCourse as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useUnenrollFromCourse(), { + wrapper: createWrapper(), + }); + + await expect(result.current.mutateAsync({ courseId: 'test-course-id' })) + .rejects.toThrow('Network error'); + + expect(mockLogError).toHaveBeenCalledWith( + 'Failed to unenroll from course test-course-id:', + error, + ); + }); + }); + + describe('useUpdateEntitlementEnrollment', () => { + it('should update entitlement enrollment successfully', async () => { + const queryClient = new QueryClient(); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + (api.updateEntitlementEnrollment as jest.Mock).mockResolvedValue({}); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useUpdateEntitlementEnrollment(), { wrapper }); + + await result.current.mutateAsync({ uuid: 'test-uuid', courseId: 'test-course' }); + + expect(api.updateEntitlementEnrollment).toHaveBeenCalledWith({ + uuid: 'test-uuid', + courseId: 'test-course', + }); + expect(invalidateQueriesSpy).toHaveBeenCalled(); + }); + + it('should log error when update fails', async () => { + const error = new Error('Update failed'); + (api.updateEntitlementEnrollment as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useUpdateEntitlementEnrollment(), { + wrapper: createWrapper(), + }); + + await expect(result.current.mutateAsync({ uuid: 'test-uuid', courseId: 'test-course' })) + .rejects.toThrow('Update failed'); + + expect(mockLogError).toHaveBeenCalledWith( + 'Failed to update entitlement enrollment for UUID test-uuid:', + error, + ); + }); + }); + + describe('useDeleteEntitlementEnrollment', () => { + it('should delete entitlement enrollment successfully', async () => { + const queryClient = new QueryClient(); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + (api.deleteEntitlementEnrollment as jest.Mock).mockResolvedValue({}); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useDeleteEntitlementEnrollment(), { wrapper }); + + await result.current.mutateAsync({ uuid: 'test-uuid', isRefundable: true }); + + expect(api.deleteEntitlementEnrollment).toHaveBeenCalledWith({ + uuid: 'test-uuid', + isRefundable: true, + }); + expect(invalidateQueriesSpy).toHaveBeenCalled(); + }); + + it('should log error when deletion fails', async () => { + const error = new Error('Deletion failed'); + (api.deleteEntitlementEnrollment as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useDeleteEntitlementEnrollment(), { + wrapper: createWrapper(), + }); + + await expect(result.current.mutateAsync({ uuid: 'test-uuid', isRefundable: false })) + .rejects.toThrow('Deletion failed'); + + expect(mockLogError).toHaveBeenCalledWith( + 'Failed to delete entitlement enrollment for UUID test-uuid:', + error, + ); + }); + }); + + describe('useUpdateEmailSettings', () => { + it('should update email settings successfully', async () => { + const queryClient = new QueryClient(); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + (api.updateEmailSettings as jest.Mock).mockResolvedValue({}); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useUpdateEmailSettings(), { wrapper }); + + await result.current.mutateAsync({ courseId: 'test-course', enable: true }); + + expect(api.updateEmailSettings).toHaveBeenCalledWith({ + courseId: 'test-course', + enable: true, + }); + expect(invalidateQueriesSpy).toHaveBeenCalled(); + }); + + it('should log error when email settings update fails', async () => { + const error = new Error('Email settings update failed'); + (api.updateEmailSettings as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useUpdateEmailSettings(), { + wrapper: createWrapper(), + }); + + await expect(result.current.mutateAsync({ courseId: 'test-course', enable: false })) + .rejects.toThrow('Email settings update failed'); + + expect(mockLogError).toHaveBeenCalledWith( + 'Failed to update email settings for course test-course:', + error, + ); + }); + }); + + describe('useCreateCreditRequest', () => { + it('should create credit request successfully', async () => { + const queryClient = new QueryClient(); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + (api.createCreditRequest as jest.Mock).mockResolvedValue({}); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateCreditRequest(), { wrapper }); + + const creditParams = { + providerId: 'test-provider', + courseId: 'test-course', + username: 'test-user', + }; + + await result.current.mutateAsync(creditParams); + + expect(api.createCreditRequest).toHaveBeenCalledWith(creditParams); + expect(invalidateQueriesSpy).toHaveBeenCalled(); + }); + + it('should log error when credit request creation fails', async () => { + const error = new Error('Credit request failed'); + (api.createCreditRequest as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useCreateCreditRequest(), { + wrapper: createWrapper(), + }); + + const creditParams = { + providerId: 'test-provider', + courseId: 'test-course', + username: 'test-user', + }; + + await expect(result.current.mutateAsync(creditParams)) + .rejects.toThrow('Credit request failed'); + + expect(mockLogError).toHaveBeenCalledWith( + 'Failed to create credit request for course test-course with provider test-provider:', + error, + ); + }); + }); + + describe('useSendConfirmEmail', () => { + it('should send confirmation email successfully', async () => { + const queryClient = new QueryClient(); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + (api.sendConfirmEmail as jest.Mock).mockResolvedValue({}); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const sendEmailUrl = 'https://example.com/send-email'; + const { result } = renderHook(() => useSendConfirmEmail(sendEmailUrl), { wrapper }); + + await result.current.mutateAsync(); + + expect(api.sendConfirmEmail).toHaveBeenCalledWith(sendEmailUrl); + expect(invalidateQueriesSpy).toHaveBeenCalled(); + }); + + it('should log error when sending confirmation email fails', async () => { + const error = new Error('Email sending failed'); + (api.sendConfirmEmail as jest.Mock).mockRejectedValue(error); + + const sendEmailUrl = 'https://example.com/send-email'; + const { result } = renderHook(() => useSendConfirmEmail(sendEmailUrl), { + wrapper: createWrapper(), + }); + + await expect(result.current.mutateAsync()) + .rejects.toThrow('Email sending failed'); + + expect(mockLogError).toHaveBeenCalledWith( + 'Failed to send confirmation email:', + error, + ); + }); + }); + + describe('Query invalidation behavior', () => { + it('should invalidate correct queries for all mutation hooks', async () => { + const queryClient = new QueryClient(); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Mock successful API responses + (api.unenrollFromCourse as jest.Mock).mockResolvedValue({}); + (api.updateEntitlementEnrollment as jest.Mock).mockResolvedValue({}); + (api.updateEmailSettings as jest.Mock).mockResolvedValue({}); + (api.createCreditRequest as jest.Mock).mockResolvedValue({}); + (api.sendConfirmEmail as jest.Mock).mockResolvedValue({}); + + // Test each hook that should invalidate queries + const hooks = [ + { hook: useUnenrollFromCourse, params: { courseId: 'test' } }, + { hook: useUpdateEntitlementEnrollment, params: { uuid: 'test', courseId: 'test' } }, + { hook: useUpdateEmailSettings, params: { courseId: 'test', enable: true } }, + { hook: useCreateCreditRequest, params: { providerId: 'test', courseId: 'test', username: 'test' } }, + { hook: useSendConfirmEmail, params: undefined, arg: 'https://test.com' }, + ]; + + for (const { hook, params, arg } of hooks) { + invalidateQueriesSpy.mockClear(); + // @ts-expect-error handle varying params + const { result } = renderHook(() => (arg ? hook(arg) : hook()), { wrapper }); + + // @ts-expect-error handle varying mutateAsync params + await result.current.mutateAsync(params); + + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ + queryKey: expect.arrayContaining(['learner-dashboard', 'initialize']), + }); + } + }); + }); +}); diff --git a/src/data/hooks/mutationHooks.ts b/src/data/hooks/mutationHooks.ts new file mode 100644 index 000000000..c09395e41 --- /dev/null +++ b/src/data/hooks/mutationHooks.ts @@ -0,0 +1,131 @@ +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { logError } from '@openedx/frontend-base'; +import { + createCreditRequest, + deleteEntitlementEnrollment, + sendConfirmEmail, + unenrollFromCourse, + updateEmailSettings, + updateEntitlementEnrollment, +} from '@src/data/services/lms/api'; +import { learnerDashboardQueryKeys, learnerDashboardMutationKeys } from './queryKeys'; + +interface UpdateEntitlementProps { + uuid: string, + courseId: string, +} + +interface DeleteEntitlementParams { + uuid: string, + isRefundable: boolean, +} + +interface UpdateEmailSettingsParams { + courseId: string, + enable: boolean, +} + +interface CreditParams { + providerId: string, + courseId: string, + username: string, +} + +const useUnenrollFromCourse = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: learnerDashboardMutationKeys.unenrollFromCourse(), + mutationFn: ({ courseId }: { courseId: string }) => unenrollFromCourse({ courseId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: learnerDashboardQueryKeys.initializeBase() }); + }, + onError: (error, variables) => { + logError(`Failed to unenroll from course ${variables.courseId}:`, error); + }, + }); +}; + +const useUpdateEntitlementEnrollment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: learnerDashboardMutationKeys.updateEntitlementEnrollment(), + mutationFn: ({ uuid, courseId }: UpdateEntitlementProps) => updateEntitlementEnrollment({ uuid, courseId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: learnerDashboardQueryKeys.initializeBase() }); + }, + onError: (error, variables) => { + logError(`Failed to update entitlement enrollment for UUID ${variables.uuid}:`, error); + }, + }); +}; + +const useDeleteEntitlementEnrollment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: learnerDashboardMutationKeys.deleteEntitlementEnrollment(), + mutationFn: (params: DeleteEntitlementParams) => deleteEntitlementEnrollment(params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: learnerDashboardQueryKeys.initializeBase() }); + }, + onError: (error, variables) => { + logError(`Failed to delete entitlement enrollment for UUID ${variables.uuid}:`, error); + }, + }); +}; + +const useUpdateEmailSettings = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: learnerDashboardMutationKeys.updateEmailSettings(), + mutationFn: ({ courseId, enable }: UpdateEmailSettingsParams) => updateEmailSettings({ courseId, enable }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: learnerDashboardQueryKeys.initializeBase() }); + }, + onError: (error, variables) => { + logError(`Failed to update email settings for course ${variables.courseId}:`, error); + }, + }); +}; + +const useCreateCreditRequest = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: learnerDashboardMutationKeys.createCreditRequest(), + mutationFn: (props: CreditParams) => createCreditRequest(props), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: learnerDashboardQueryKeys.initializeBase() }); + }, + onError: (error, variables) => { + logError(`Failed to create credit request for course ${variables.courseId} with provider ${variables.providerId}:`, error); + }, + }); +}; + +const useSendConfirmEmail = (sendEmailUrl: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: learnerDashboardMutationKeys.sendConfirmEmail(sendEmailUrl), + mutationFn: () => sendConfirmEmail(sendEmailUrl), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: learnerDashboardQueryKeys.initializeBase() }); + }, + onError: (error) => { + logError('Failed to send confirmation email:', error); + }, + }); +}; + +export { + useUnenrollFromCourse, + useUpdateEntitlementEnrollment, + useDeleteEntitlementEnrollment, + useUpdateEmailSettings, + useCreateCreditRequest, + useSendConfirmEmail, +}; diff --git a/src/data/hooks/queryHooks.test.tsx b/src/data/hooks/queryHooks.test.tsx new file mode 100644 index 000000000..3c1528de5 --- /dev/null +++ b/src/data/hooks/queryHooks.test.tsx @@ -0,0 +1,222 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useMasquerade } from '@src/data/context'; +import { + useInitializeLearnerHome, +} from './index'; +import { learnerDashboardQueryKeys } from './queryKeys'; +import * as api from '../services/lms/api'; + +// Mock external dependencies +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + logError: jest.fn(), +})); +jest.mock('@src/data/context'); +jest.mock('@src/data/services/lms/api'); +jest.mock('@src/utils/dataTransformers', () => ({ + getTransformedCourseDataObject: jest.fn((courses) => { + const result = {}; + (courses || []).forEach((c, i) => { + result[`card-${i}`] = { ...c, cardId: `card-${i}` }; + }); + return result; + }), +})); +jest.mock('@src/data/contexts/GlobalDataContext', () => { + const { createContext } = jest.requireActual('react'); + return { + __esModule: true, + default: createContext({ + setEmailConfirmation: jest.fn(), + setPlatformSettings: jest.fn(), + }), + }; +}); + +const mockUseMasquerade = useMasquerade as jest.MockedFunction; + +// Create a test wrapper with QueryClient +const createWrapper = (queryClient?: QueryClient) => { + const client = queryClient || new QueryClient({ + defaultOptions: { + queries: { + retry: false, + retryDelay: 0, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; +}; + +describe('queryHooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('useInitializeLearnerHome', () => { + const mockQueryData = { courses: ['query-course'], user: 'query-user' }; + const mockNormalUserData = { courses: ['normal-course'], user: 'normal-user', coursesByCardId: {} }; + + it('should fetch and return data with coursesByCardId for normal user', async () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser(): void { + throw new Error('Function not implemented.'); + }, + }); + const mockApiData = { + courses: [{ id: 'course-1' }, { id: 'course-2' }], + emailConfirmation: { isNeeded: false }, + platformSettings: { supportEmail: 'test@example.com' }, + }; + (api.initializeList as jest.Mock).mockResolvedValue(mockApiData); + + const { result } = renderHook(() => useInitializeLearnerHome(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(api.initializeList).toHaveBeenCalledWith(undefined); + expect(result.current.data).toMatchObject(mockApiData); + expect(result.current.data?.coursesByCardId).toEqual({ + 'card-0': { id: 'course-1', cardId: 'card-0' }, + 'card-1': { id: 'course-2', cardId: 'card-1' }, + }); + }); + + it('should use query data when masquerading and query succeeds', async () => { + const masqueradeUser = 'test-user'; + mockUseMasquerade.mockReturnValue({ + masqueradeUser, + setMasqueradeUser(): void { + throw new Error('Function not implemented.'); + }, + }); + (api.initializeList as jest.Mock).mockResolvedValue(mockQueryData); + + const { result } = renderHook(() => useInitializeLearnerHome(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(api.initializeList).toHaveBeenCalledWith(masqueradeUser); + expect(result.current.data).toMatchObject(mockQueryData); + expect(result.current.data).toHaveProperty('coursesByCardId'); + }); + + it('should fall back to cached normal-user data when masquerading fails', async () => { + const masqueradeUser = 'test-user'; + mockUseMasquerade.mockReturnValue({ + masqueradeUser, + setMasqueradeUser(): void { + throw new Error('Function not implemented.'); + }, + }); + const error: any = new Error('API Error'); + error.response = { status: 403 }; + (api.initializeList as jest.Mock).mockRejectedValue(error); + + // Don't use gcTime: 0 here — we need the seeded cache entry to persist + // for the fallback lookup via queryClient.getQueryData() + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 }, mutations: { retry: false } }, + }); + queryClient.setQueryData( + learnerDashboardQueryKeys.initialize(undefined), + mockNormalUserData, + ); + + const { result } = renderHook(() => useInitializeLearnerHome(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(api.initializeList).toHaveBeenCalledWith(masqueradeUser); + expect(result.current.data).toEqual(mockNormalUserData); + }); + + it('should not retry on 4xx errors', async () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser(): void { + throw new Error('Function not implemented.'); + }, + }); + const error: any = new Error('Forbidden'); + error.response = { status: 403 }; + (api.initializeList as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useInitializeLearnerHome(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + // 4xx errors should not be retried — only 1 call + expect(api.initializeList).toHaveBeenCalledTimes(1); + }); + + it('should retry on 5xx errors up to 3 times', async () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser(): void { + throw new Error('Function not implemented.'); + }, + }); + const error: any = new Error('Server Error'); + error.response = { status: 500 }; + (api.initializeList as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useInitializeLearnerHome(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + // 1 initial + 3 retries = 4 total calls + expect(api.initializeList).toHaveBeenCalledTimes(4); + }); + + it('should have correct query configuration for masquerading', async () => { + const masqueradeUser = 'test-user'; + mockUseMasquerade.mockReturnValue({ + masqueradeUser, + setMasqueradeUser(): void { + throw new Error('Function not implemented.'); + }, + }); + (api.initializeList as jest.Mock).mockResolvedValue(mockQueryData); + + const { result } = renderHook(() => useInitializeLearnerHome(), { + wrapper: createWrapper(), + }); + + // For masquerading, retryOnMount and refetchOnMount should be false + expect(result.current.isRefetchError).toBe(false); + }); + }); +}); diff --git a/src/data/hooks/queryHooks.ts b/src/data/hooks/queryHooks.ts new file mode 100644 index 000000000..ab0ee4e82 --- /dev/null +++ b/src/data/hooks/queryHooks.ts @@ -0,0 +1,58 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useContext, useEffect } from 'react'; +import { useMasquerade } from '@src/data/context'; +import GlobalDataContext from '@src/data/contexts/GlobalDataContext'; +import { + initializeList, +} from '@src/data/services/lms/api'; +import { getTransformedCourseDataObject } from '@src/utils/dataTransformers'; +import { learnerDashboardQueryKeys } from './queryKeys'; + +const useInitializeLearnerHome = () => { + const { masqueradeUser } = useMasquerade(); + const queryClient = useQueryClient(); + const { setEmailConfirmation, setPlatformSettings } = useContext(GlobalDataContext); + + const query = useQuery({ + queryKey: learnerDashboardQueryKeys.initialize(masqueradeUser), + queryFn: async () => { + const data = await initializeList(masqueradeUser); + return { + ...data, + coursesByCardId: getTransformedCourseDataObject(data?.courses || []), + }; + }, + staleTime: 5 * 60 * 1000, // 5 minutes — dashboard data rarely changes while viewing + retry: (failureCount, error: any) => { + // Don't retry client errors (4xx) — they won't resolve on retry + if (error?.response?.status >= 400 && error?.response?.status < 500) return false; + return failureCount < 3; + }, + retryOnMount: !masqueradeUser, + refetchOnMount: !masqueradeUser, + }); + + // Populate shell-level GlobalDataProvider so header widgets can access this data + useEffect(() => { + if (query.data && !masqueradeUser) { + if (query.data.emailConfirmation && setEmailConfirmation) { + setEmailConfirmation(query.data.emailConfirmation); + } + if (query.data.platformSettings && setPlatformSettings) { + setPlatformSettings(query.data.platformSettings); + } + } + }, [masqueradeUser, query.data, setEmailConfirmation, setPlatformSettings]); + + // When masquerading fails, fall back to the normal user's cached data + let { data } = query; + if (masqueradeUser && query.isError) { + data = queryClient.getQueryData(learnerDashboardQueryKeys.initialize(undefined)); + } + + return { ...query, data }; +}; + +export { + useInitializeLearnerHome, +}; diff --git a/src/data/hooks/queryKeys.ts b/src/data/hooks/queryKeys.ts new file mode 100644 index 000000000..e1ebe62dd --- /dev/null +++ b/src/data/hooks/queryKeys.ts @@ -0,0 +1,16 @@ +const BASE_KEY = ['learner-dashboard'] as const; + +export const learnerDashboardQueryKeys = { + all: BASE_KEY, + initializeBase: () => [...BASE_KEY, 'initialize'] as const, + initialize: (masqueradedUser?: string | null) => [...BASE_KEY, 'initialize', masqueradedUser] as const, +}; + +export const learnerDashboardMutationKeys = { + unenrollFromCourse: () => [...BASE_KEY, 'unenrollFromCourse'] as const, + updateEntitlementEnrollment: () => [...BASE_KEY, 'updateEntitlementEnrollment'] as const, + deleteEntitlementEnrollment: () => [...BASE_KEY, 'deleteEntitlementEnrollment'] as const, + updateEmailSettings: () => [...BASE_KEY, 'updateEmailSettings'] as const, + createCreditRequest: () => [...BASE_KEY, 'createCreditRequest'] as const, + sendConfirmEmail: (sendEmailUrl: string) => [...BASE_KEY, 'sendConfirmEmail', sendEmailUrl] as const, +}; diff --git a/src/data/redux/app/index.js b/src/data/redux/app/index.js deleted file mode 100644 index 8abd5f91d..000000000 --- a/src/data/redux/app/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { actions, reducer } from './reducer'; -export { default as selectors } from './selectors'; diff --git a/src/data/redux/app/reducer.js b/src/data/redux/app/reducer.js deleted file mode 100644 index ffe92938a..000000000 --- a/src/data/redux/app/reducer.js +++ /dev/null @@ -1,81 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; - -import { StrictDict } from '../../../utils'; - -const initialState = { - pageNumber: 1, - courseData: {}, - entitlement: [], - emailConfirmation: {}, - enterpriseDashboard: {}, - platformSettings: {}, - suggestedCourses: [], - selectSessionModal: {}, - filters: [], -}; - -export const cardId = (val) => `card-${val}`; - -export const today = Date.now(); - -/** - * Creates a redux slice with actions to load dashboard data and manage visual layout - */ -const app = createSlice({ - name: 'app', - initialState, - reducers: { - loadCourses: (state, { payload: { courses } }) => ({ - ...state, - courseData: courses.reduce( - (obj, curr, index) => { - const out = { ...curr, cardId: cardId(index) }; - if (out.enrollment.lastEnrolled === null) { - out.enrollment.lastEnrolled = today; - } - return { ...obj, [cardId(index)]: out }; - }, - {}, - ), - }), - loadGlobalData: (state, { payload }) => ({ - ...state, - emailConfirmation: payload.emailConfirmation, - enterpriseDashboard: payload.enterpriseDashboard, - platformSettings: payload.platformSettings, - suggestedCourses: payload.suggestedCourses, - socialShareSettings: payload.socialShareSettings, - }), - updateSelectSessionModal: (state, { payload }) => ({ - ...state, - selectSessionModal: { cardId: payload }, - }), - setPageNumber: (state, { payload }) => ({ ...state, pageNumber: payload }), - setFilters: (state, { payload }) => ({ - ...state, - filters: payload, - }), - addFilter: (state, { payload }) => ({ - ...state, - filters: [...state.filters, payload], - }), - removeFilter: (state, { payload }) => ({ - ...state, - filters: state.filters.filter(item => item !== payload), - }), - clearFilters: (state) => ({ - ...state, - filters: [], - }), - }, -}); - -const actions = StrictDict(app.actions); - -const { reducer } = app; - -export { - actions, - initialState, - reducer, -}; diff --git a/src/data/redux/app/reducer.test.js b/src/data/redux/app/reducer.test.js deleted file mode 100644 index 5d542223d..000000000 --- a/src/data/redux/app/reducer.test.js +++ /dev/null @@ -1,124 +0,0 @@ -import { - cardId, - initialState, - reducer, - actions, - today, -} from './reducer'; - -describe('app reducer', () => { - describe('reducers', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - it('returns initial state', () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - const initialFilter = 'initial filter'; - const testState = { - ...initialState, - enrollments: [], - courseData: { - }, - entitlement: [], - filters: [initialFilter], - }; - describe('action handlers', () => { - describe('loadCourses', () => { - const courseIds = [ - 'course-1', - 'course-2', - 'course-3', - ]; - const entitlementIds = [ - 'entitlement-course-1', - 'entitlement-course-2', - ]; - const enrollmentData = [ - { - courseRun: { cardId: courseIds[0] }, - course: 1, - some: 'data', - enrollment: { lastEnrolled: 'test-last-enrolled' }, - }, - { - courseRun: { cardId: courseIds[1] }, - course: 2, - some: 'other data', - enrollment: { lastEnrolled: 'test-last-enrolled' }, - }, - { - courseRun: { cardId: courseIds[2] }, - course: 3, - some: 'still different data', - enrollment: { lastEnrolled: 'test-last-enrolled' }, - }, - ]; - const entitlementData = [ - { - courseRun: { cardId: entitlementIds[0] }, - course: 4, - some: 'STILL different data', - enrollment: { lastEnrolled: null }, - }, - { - courseRun: { cardId: entitlementIds[1] }, - course: 5, - some: 'still DIFFERENT data', - enrollment: { lastEnrolled: null }, - }, - ]; - let out; - beforeEach(() => { - out = reducer(testState, actions.loadCourses({ - courses: [...enrollmentData, ...entitlementData], - })); - }); - it('loads object keyed by courseRun ids into courseData field', () => { - expect(out.courseData).toEqual({ - [cardId(0)]: { ...enrollmentData[0], cardId: cardId(0) }, - [cardId(1)]: { ...enrollmentData[1], cardId: cardId(1) }, - [cardId(2)]: { - ...enrollmentData[2], - cardId: cardId(2), - }, - [cardId(3)]: { - ...entitlementData[0], - cardId: cardId(3), - enrollment: { lastEnrolled: today }, - }, - [cardId(4)]: { - ...entitlementData[1], - cardId: cardId(4), - enrollment: { lastEnrolled: today }, - }, - }); - }); - }); - describe('filters', () => { - const newFilter = 'new filter'; - let out; - beforeEach(() => { - out = reducer(testState, {}); - }); - it('overwrites the filters object when using setFilters', () => { - expect(out.filters).toEqual([initialFilter]); - out = reducer(testState, actions.setFilters([newFilter])); - expect(out.filters).toEqual([newFilter]); - }); - it('adds a filter when using addFilter', () => { - out = reducer(testState, actions.addFilter(newFilter)); - expect(out.filters).toEqual([initialFilter, newFilter]); - }); - it('removes a filter when using removeFilter', () => { - out = reducer(testState, actions.removeFilter(initialFilter)); - expect(out.filters).toEqual([]); - }); - it('clears the filters when using clearFilters', () => { - out = reducer(testState, actions.clearFilters()); - expect(out.filters).toEqual([]); - }); - }); - }); - }); -}); diff --git a/src/data/redux/app/selectors/appSelectors.js b/src/data/redux/app/selectors/appSelectors.js deleted file mode 100644 index 8a28f3f58..000000000 --- a/src/data/redux/app/selectors/appSelectors.js +++ /dev/null @@ -1,23 +0,0 @@ -import { createSelector } from 'reselect'; - -import { StrictDict } from '../../../../utils'; - -import simpleSelectors from './simpleSelectors'; -import * as module from './appSelectors'; - -export const numCourses = createSelector( - [simpleSelectors.courseData], - (courseData) => Object.keys(courseData).length, -); -export const hasCourses = createSelector([module.numCourses], (num) => num > 0); - -export const showSelectSessionModal = createSelector( - [simpleSelectors.selectSessionModal], - (data) => data.cardId != null, -); - -export default StrictDict({ - numCourses, - hasCourses, - showSelectSessionModal, -}); diff --git a/src/data/redux/app/selectors/appSelectors.test.js b/src/data/redux/app/selectors/appSelectors.test.js deleted file mode 100644 index 09b528c08..000000000 --- a/src/data/redux/app/selectors/appSelectors.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import simpleSelectors from './simpleSelectors'; -import * as appSelectors from './appSelectors'; - -describe('basic app selectors', () => { - describe('numCourses', () => { - it('returns the number of courses in the courseData object', () => { - const { preSelectors, cb } = appSelectors.numCourses; - expect(preSelectors).toEqual([simpleSelectors.courseData]); - expect(cb({ course1: 'data', course2: 'data', course3: 'data' })).toEqual(3); - }); - }); - describe('hasCourses', () => { - it('returns true iff numCourses is greater than 0', () => { - const { preSelectors, cb } = appSelectors.hasCourses; - expect(preSelectors).toEqual([appSelectors.numCourses]); - expect(cb(3)).toEqual(true); - expect(cb(0)).toEqual(false); - }); - }); - describe('showSelectSessionModal', () => { - it('returns true if the selectSessionModal cardId is not null', () => { - const { preSelectors, cb } = appSelectors.showSelectSessionModal; - expect(preSelectors).toEqual([simpleSelectors.selectSessionModal]); - expect(cb({ cardId: 'test' })).toEqual(true); - expect(cb({ data: 'test' })).toEqual(false); - }); - }); -}); diff --git a/src/data/redux/app/selectors/courseCard.js b/src/data/redux/app/selectors/courseCard.js deleted file mode 100644 index 833b6a996..000000000 --- a/src/data/redux/app/selectors/courseCard.js +++ /dev/null @@ -1,155 +0,0 @@ -import { StrictDict } from '../../../../utils'; -import { baseAppUrl } from '../../../../data/services/lms/urls'; -import { EXECUTIVE_EDUCATION_COURSE_MODES } from '../../../../data/constants/course'; - -import * as module from './courseCard'; -import * as simpleSelectors from './simpleSelectors'; - -const { cardSimpleSelectors, mkCardSelector } = simpleSelectors; - -const today = new Date(); -const dateSixMonthsFromNow = new Date(); -dateSixMonthsFromNow.setDate(dateSixMonthsFromNow.getDate() + 180); - -export const loadDateVal = (date) => (date ? new Date(date) : null); - -export const courseCard = StrictDict({ - certificate: mkCardSelector( - cardSimpleSelectors.certificate, - (certificate) => (certificate === null ? {} : ({ - availableDate: new Date(certificate.availableDate), - certPreviewUrl: baseAppUrl(certificate.certPreviewUrl), - isDownloadable: certificate.isDownloadable, - isEarnedButUnavailable: certificate.isEarned && new Date(certificate.availableDate) > new Date(), - isRestricted: certificate.isRestricted, - isEarned: certificate.isEarned, - })), - ), - course: mkCardSelector( - cardSimpleSelectors.course, - (course) => ({ - bannerImgSrc: baseAppUrl(course.bannerImgSrc), - courseNumber: course.courseNumber, - courseName: course.courseName, - socialShareUrl: course.socialShareUrl, - }), - ), - courseProvider: mkCardSelector( - cardSimpleSelectors.courseProvider, - (courseProvider) => ({ name: courseProvider?.name }), - ), - courseRun: mkCardSelector( - cardSimpleSelectors.courseRun, - (courseRun) => (courseRun === null ? {} : { - endDate: module.loadDateVal(courseRun.endDate), - startDate: module.loadDateVal(courseRun.startDate), - advertisedStart: courseRun.advertisedStart, - - courseId: courseRun.courseId, - isArchived: courseRun.isArchived, - isStarted: courseRun.isStarted, - - minPassingGrade: Math.floor(courseRun.minPassingGrade * 100), - - homeUrl: courseRun.homeUrl, - marketingUrl: courseRun.marketingUrl, - - progressUrl: baseAppUrl(courseRun.progressUrl), - resumeUrl: baseAppUrl(courseRun.resumeUrl), // resume will route this to learning mfe. - unenrollUrl: baseAppUrl(courseRun.unenrollUrl), - }), - ), - credit: mkCardSelector( - cardSimpleSelectors.credit, - (credit) => { - if (!credit || Object.keys(credit).length === 0) { - return { isEligible: false }; - } - return { - isEligible: true, - providerStatusUrl: credit.providerStatusUrl, - providerName: credit.providerName, - providerId: credit.providerId, - error: credit.error, - purchased: credit.purchased, - requestStatus: credit.requestStatus, - }; - }, - ), - enrollment: mkCardSelector( - cardSimpleSelectors.enrollment, - (enrollment) => { - if (enrollment == null) { - return { isEnrolled: false }; - } - const { isStaff, hasUnmetPrereqs, isTooEarly } = enrollment.coursewareAccess; - return { - coursewareAccess: enrollment.coursewareAccess, - hasAccess: isStaff || !(hasUnmetPrereqs || isTooEarly), - isEnrolled: enrollment.isEnrolled, - lastEnrolled: enrollment.lastEnrolled, - hasStarted: enrollment.hasStarted, - - accessExpirationDate: module.loadDateVal(enrollment.accessExpirationDate), - canUpgrade: enrollment.canUpgrade, - isAudit: enrollment.isAudit, - isAuditAccessExpired: enrollment.isAuditAccessExpired, - isVerified: enrollment.isVerified, - - isEmailEnabled: enrollment.isEmailEnabled, - hasOptedOutOfEmail: enrollment.hasOptedOutOfEmail, - mode: enrollment.mode, - isExecEd2UCourse: EXECUTIVE_EDUCATION_COURSE_MODES.includes(enrollment.mode), - }; - }, - ), - entitlement: mkCardSelector( - cardSimpleSelectors.entitlement, - (entitlement) => { - if (!entitlement || Object.keys(entitlement).length === 0) { - return { isEntitlement: false }; - } - const deadline = new Date(entitlement.changeDeadline); - const deadlinePassed = deadline < today; - const showExpirationWarning = ( - !entitlement.isFulfilled - && !deadlinePassed - && deadline <= dateSixMonthsFromNow - ); - return { - isEntitlement: true, - - availableSessions: entitlement.availableSessions, - changeDeadline: deadline, - isExpired: entitlement.isExpired, - isFulfilled: entitlement.isFulfilled, - uuid: entitlement.uuid, - - hasSessions: entitlement.availableSessions?.length > 0, - canChange: !deadlinePassed, - showExpirationWarning, - }; - }, - ), - gradeData: mkCardSelector( - cardSimpleSelectors.gradeData, - (gradeData) => ({ isPassing: gradeData.isPassing }), - ), - relatedPrograms: mkCardSelector( - cardSimpleSelectors.relatedPrograms, - (relatedPrograms) => ({ - list: relatedPrograms.map(program => ({ - bannerImgSrc: program.bannerImgSrc, - logoImgSrc: program.logoImgSrc, - numberOfCourses: program.numberOfCourses, - programType: program.programType, - programUrl: program.programUrl, - provider: program.provider, - title: program.title, - })), - length: relatedPrograms.length, - }), - ), -}); - -export default courseCard; diff --git a/src/data/redux/app/selectors/courseCard.test.js b/src/data/redux/app/selectors/courseCard.test.js deleted file mode 100644 index 8cfd9f28f..000000000 --- a/src/data/redux/app/selectors/courseCard.test.js +++ /dev/null @@ -1,398 +0,0 @@ -import { keyStore } from '@src/utils'; -import { baseAppUrl } from '@src/data/services/lms/urls'; -import { EXECUTIVE_EDUCATION_COURSE_MODES } from '@src/data/constants/course'; - -import simpleSelectors from './simpleSelectors'; -import * as module from './courseCard'; - -jest.mock('@src/data/services/lms/urls', () => ({ - baseAppUrl: url => ({ baseAppUrl: url }), -})); - -jest.mock('./simpleSelectors', () => ({ - mkCardSelector: (simpleSelector, selector) => ({ - mkCardSelector: { selector, simpleSelector }, - }), - cardSimpleSelectors: jest.requireActual('./simpleSelectors').cardSimpleSelectors, -})); - -const { courseCard } = module; -const { cardSimpleSelectors } = simpleSelectors; - -const moduleKeys = keyStore(module); - -let testData; -let selector; -let simpleSelector; -let selected; - -const dates = { - today: new Date(), - tomorrow: new Date(), - nextYear: new Date(), - yesterday: new Date(), -}; -dates.tomorrow.setDate(dates.tomorrow.getDate() + 1); -dates.nextYear.setDate(dates.nextYear.getDate() + 365); -dates.yesterday.setDate(dates.yesterday.getDate() - 1); - -/* - * Takes a selector as input and fetches the referenced selector and simpleSelector for - * the selector to be tested, to be stored in global variables. - * Also sets `selected` global variable is loaded with the passed data, which is stored - * as `testData`. - */ -const loadSelector = (sel, data) => { - ({ simpleSelector, selector } = sel.mkCardSelector); - testData = data; - selected = selector(data); -}; - -describe('courseCard selectors module', () => { - describe('loadDateVal helper function', () => { - it('returns passed date value converted to Date', () => { - const testDate = '2000-10-10'; - expect(module.loadDateVal(testDate)).toEqual(new Date(testDate)); - }); - it('returns null if no value is passed', () => { - expect(module.loadDateVal()).toEqual(null); - }); - }); - describe('courseCard selectors', () => { - let dateSpy; - beforeEach(() => { - dateSpy = jest.spyOn(module, moduleKeys.loadDateVal); - dateSpy.mockImplementation(v => new Date(v)); - }); - afterEach(() => { - dateSpy.mockRestore(); - }); - describe('certificate selector', () => { - beforeEach(() => { - loadSelector(courseCard.certificate, { - certPreviewUrl: 'test-cert-preview-url', - isDownloadable: 'test-is-downloadable', - isRestricted: 'test-is-restricted', - isEarned: false, - availableDate: '2020-10-10', - }); - }); - it('returns a card selector based on certificate cardSimpleSelector', () => { - expect(simpleSelector).toEqual(cardSimpleSelectors.certificate); - }); - it('returns {} object if null certificate received', () => { - expect(selector(null)).toEqual({}); - }); - it('passes availableDate, converted to a date', () => { - expect(selected.availableDate).toMatchObject(new Date(testData.availableDate)); - }); - it('passes [isDownloadable, isRestricted]', () => { - expect(selected.isDownloadable).toEqual(testData.isDownloadable); - expect(selected.isRestricted).toEqual(testData.isRestricted); - }); - it('passes certPreviewUrl as app url', () => { - expect(selected.certPreviewUrl).toEqual(baseAppUrl(testData.certPreviewUrl)); - }); - describe('isEarnedButUnavailable', () => { - it('passes true iff certificate is earned but availableDate is in the future', () => { - const testSelector = (data, expected) => { - expect(selector({ ...testData, ...data }).isEarnedButUnavailable).toEqual(expected); - }; - testSelector({ isEarned: true, availableDate: dates.today }, false); - testSelector({ isEarned: true, availableDate: dates.yesterday }, false); - testSelector({ isEarned: true, availableDate: dates.tomorrow }, true); - testSelector({ isEarned: false, availableDate: dates.tomorrow }, false); - }); - }); - }); - describe('course selector', () => { - beforeEach(() => { - loadSelector(courseCard.course, { - bannerImgSrc: 'test-banner-img-src', - courseNumber: 'test-course-number', - courseName: 'test-course-name', - socialShareUrl: 'test-social-share-url', - }); - }); - it('returns a card selector based on course cardSimpleSelector', () => { - expect(simpleSelector).toEqual(cardSimpleSelectors.course); - }); - it('passes bannerImgSrc, converted to a baseAppUrl', () => { - expect(selected.bannerImgSrc).toEqual(baseAppUrl(testData.bannerImgSrc)); - }); - it('passes [courseNumber, courseName, socialShareUrl]', () => { - expect(selected.courseNumber).toEqual(testData.courseNumber); - expect(selected.courseName).toEqual(testData.courseName); - expect(selected.socialShareUrl).toEqual(testData.socialShareUrl); - }); - }); - describe('courseProvider selector', () => { - beforeEach(() => { - loadSelector(courseCard.courseProvider, { name: 'test-provider-name' }); - }); - it('returns a card selector based on courseProvider cardSimpleSelector', () => { - expect(simpleSelector).toEqual(cardSimpleSelectors.courseProvider); - }); - describe('name', () => { - it('passes the provider name if provider is known', () => { - expect(selected.name).toEqual(testData.name); - }); - it('passes undefined if provider is not known', () => { - expect(selector().name).toEqual(undefined); - }); - }); - }); - describe('courseRun selector', () => { - beforeEach(() => { - loadSelector(courseCard.courseRun, { - endDate: '3000-10-20', - startDate: '2000-10-20', - advertisedStart: 'Mid June', - - courseId: 'test-course-id', - isArchived: 'test-is-archived', - isStarted: 'test-is-started', - - minPassingGrade: 0.9354, - - homeUrl: 'test-home-url', - marketingUrl: 'test-marketing-url', - - progressUrl: 'test-progress-url', - resumeUrl: 'test-resume-url', - unenrollUrl: 'test-unenroll-url', - }); - }); - it('returns a card selector based on courseRun cardSimpleSelector', () => { - expect(simpleSelector).toEqual(cardSimpleSelectors.courseRun); - }); - it('returns {} object if null courseRun received', () => { - expect(selector(null)).toEqual({}); - }); - it('passes [endDate, startDate], converted to dates', () => { - expect(selected.endDate).toEqual(new Date(testData.endDate)); - expect(selected.startDate).toEqual(new Date(testData.startDate)); - }); - it('passes advertised start date', () => { - expect(selected.advertisedStart).toEqual(testData.advertisedStart); - }); - it('passes [courseId, isArchived, isStarted]', () => { - expect(selected.courseId).toEqual(testData.courseId); - expect(selected.isArchived).toEqual(testData.isArchived); - expect(selected.isStarted).toEqual(testData.isStarted); - }); - it('passes minPassingGrade floored from float to a percentage value', () => { - expect(selected.minPassingGrade).toEqual(93); - }); - it('passes [homeUrl, marketingUrl]', () => { - expect(selected.homeUrl).toEqual(testData.homeUrl); - expect(selected.marketingUrl).toEqual(testData.marketingUrl); - }); - it('passes [progressUrl, unenrollUrl, resumeUrl], converted to baseAppUrl', () => { - expect(selected.progressUrl).toEqual(baseAppUrl(testData.progressUrl)); - expect(selected.resumeUrl).toEqual(baseAppUrl(testData.resumeUrl)); - expect(selected.unenrollUrl).toEqual(baseAppUrl(testData.unenrollUrl)); - }); - }); - describe('credit selector', () => { - const credit = { - providerStatusUrl: 'test-provider-status-url', - providerName: 'test-provider-name', - providerId: 'test-provider-id', - error: 'test-provider-id', - purchased: 'test-purchased', - requestStatus: 'test-request-status', - }; - it('returns a card selector based on credit cardSimpleSelector', () => { - loadSelector(courseCard.credit, {}); - expect(simpleSelector).toEqual(cardSimpleSelectors.credit); - }); - it('returns { isEligible: false } if empty object received for credit', () => { - loadSelector(courseCard.credit, {}); - expect(selected).toEqual({ isEligible: false }); - }); - describe('credit fields when credit object is passed', () => { - beforeEach(() => { - loadSelector(courseCard.credit, credit); - }); - it('returns isEligible: true', () => { - expect(selected.isEligible).toEqual(true); - }); - it('returns provider status url, name, and id', () => { - expect(selected.providerStatusUrl).toEqual(credit.providerStatusUrl); - expect(selected.providerName).toEqual(credit.providerName); - expect(selected.providerId).toEqual(credit.providerId); - }); - it('returns error, purchased and requestStatus fields', () => { - expect(selected.error).toEqual(credit.error); - expect(selected.purchased).toEqual(credit.purchased); - expect(selected.requestStatus).toEqual(credit.requestStatus); - }); - }); - }); - describe('enrollment selector', () => { - const defaultData = { - coursewareAccess: { - isStaff: false, - hasUnmetPrereqs: false, - isTooEarly: false, - }, - isEnrolled: 'test-is-enrolled', - lastEnrolled: 'test-last-enrolled', - hasStarted: 'test-has-started', - accessExpirationDate: '3000-10-20', - canUpgrade: 'test-can-upgrade', - isAudit: 'test-is-audit', - isAuditAccessExpired: 'test-is-audit-access-expired', - isVerified: 'test-is-verified', - isEmailEnabled: 'test-is-email-enabled', - mode: 'default', - }; - beforeEach(() => { - loadSelector(courseCard.enrollment, defaultData); - }); - it('returns a card selector based on enrollment cardSimpleSelector', () => { - expect(simpleSelector).toEqual(cardSimpleSelectors.enrollment); - }); - it('returns { isEnrolled: false } object if null enrollment received', () => { - expect(selector(null)).toEqual({ isEnrolled: false }); - }); - it('passes [coursewareAccess, hasStarted, isEnrolled, lastEnrolled]', () => { - expect(selected.coursewareAccess).toEqual(testData.coursewareAccess); - expect(selected.hasStarted).toEqual(testData.hasStarted); - expect(selected.isEnrolled).toEqual(testData.isEnrolled); - expect(selected.lastEnrolled).toEqual(testData.lastEnrolled); - }); - it('passes hasAccess if staff or neither has umet prereqs nor is too early', () => { - const testAccess = (access, expected) => { - expect(selector({ ...testData, coursewareAccess: access }).hasAccess).toEqual(expected); - }; - testAccess({ isStaff: false, hasUnmetPrereqs: false, isTooEarly: false }, true); - testAccess({ isStaff: false, hasUnmetPrereqs: false, isTooEarly: true }, false); - testAccess({ isStaff: false, hasUnmetPrereqs: true, isTooEarly: false }, false); - testAccess({ isStaff: false, hasUnmetPrereqs: true, isTooEarly: true }, false); - testAccess({ isStaff: true, hasUnmetPrereqs: true, isTooEarly: true }, true); - }); - it('passes accessExpirationDate, converted to date', () => { - expect(selected.accessExpirationDate).toEqual(new Date(testData.accessExpirationDate)); - }); - it('passes [canUpgrade, isAudit, isAuditAccessExpired, isVerified]', () => { - expect(selected.canUpgrade).toEqual(testData.canUpgrade); - expect(selected.isAudit).toEqual(testData.isAudit); - expect(selected.isAuditAccessExpired).toEqual(testData.isAuditAccessExpired); - expect(selected.isVerified).toEqual(testData.isVerified); - }); - it('passes isEmailEnabled', () => { - expect(selected.isEmailEnabled).toEqual(testData.isEmailEnabled); - }); - it('returns isExecEd2UCourse: false if mode is not in EXECUTIVE_EDUCATION_COURSE_MODES', () => { - expect(selected.isExecEd2UCourse).toEqual(false); - }); - it('returns isExecEd2UCourse: true if mode is in EXECUTIVE_EDUCATION_COURSE_MODES', () => { - loadSelector(courseCard.enrollment, { ...defaultData, mode: EXECUTIVE_EDUCATION_COURSE_MODES[0] }); - expect(selected.isExecEd2UCourse).toEqual(true); - }); - }); - describe('entitlement selector', () => { - beforeEach(() => { - loadSelector(courseCard.entitlement, { - availableSessions: ['test', 'sessions'], - changeDeadline: '2000-10-20', - isExpired: 'test-is-expired', - isFulfilled: 'test-is-fulfilled', - uuid: 'test-uuid', - }); - }); - it('returns a card selector based on entilement cardSimpleSelector', () => { - expect(simpleSelector).toEqual(cardSimpleSelectors.entitlement); - }); - it('returns { isEntilement: false } if entitlement object is missing or empty', () => { - expect(selector({})).toEqual({ isEntitlement: false }); - expect(selector()).toEqual({ isEntitlement: false }); - }); - it('passes isEntitlement: true for entilement data', () => { - expect(selected.isEntitlement).toEqual(true); - }); - it('passes [availableSessions, isExpired, isFulfilled, uuid]', () => { - expect(selected.availableSessions).toEqual(testData.availableSessions); - expect(selected.isExpired).toEqual(testData.isExpired); - expect(selected.isFulfilled).toEqual(testData.isFulfilled); - expect(selected.uuid).toEqual(testData.uuid); - }); - it('passess changeDeadline as changeDeadline, converted to a date', () => { - expect(selected.changeDeadline).toEqual(new Date(testData.changeDeadline)); - }); - it('passes hasSessions if availableSessions is provided and has a length > 0', () => { - expect(selected.hasSessions).toEqual(true); - expect(selector({ ...testData, availableSessions: [] }).hasSessions).toEqual(false); - }); - it('passes canChange if the deadline is not before current date', () => { - expect(selector({ ...testData, changeDeadline: dates.yesterday }).canChange).toEqual(false); - expect(selector({ ...testData, changeDeadline: dates.tomorrow }).canChange).toEqual(true); - }); - it('passes showExpirationWarning if the deadline is 0-6 months in the future and not fulfilled', () => { - const testSelector = ({ isFulfilled, changeDeadline }, expected) => { - expect( - selector({ ...testData, isFulfilled, changeDeadline }).showExpirationWarning, - ).toEqual(expected); - }; - testSelector({ isFulfilled: false, changeDeadline: dates.yesterday }, false); - testSelector({ isFulfilled: false, changeDeadline: dates.tomorrow }, true); - testSelector({ isFulfilled: false, changeDeadline: dates.nextYear }, false); - testSelector({ isFulfilled: true, changeDeadline: dates.nextYear }, false); - }); - }); - describe('gradeData selector', () => { - beforeEach(() => { - loadSelector(courseCard.gradeData, { isPassing: 'test-is-passing' }); - }); - it('returns a card selector based on gradeData cardSimpleSelector', () => { - expect(simpleSelector).toEqual(cardSimpleSelectors.gradeData); - }); - it('passes isPassing', () => { - expect(selected.isPassing).toEqual(testData.isPassing); - }); - }); - describe('relatedPrograms selector', () => { - beforeEach(() => { - const programData = (index) => ({ - bannerImgSrc: `test-banner-img-src-${index}`, - logoImgSrc: `test-logo-img-src-${index}`, - numberOfCourses: `test-number-of-courses-${index}`, - programType: `test-program-type-${index}`, - programUrl: `test-program-url-${index}`, - provider: `test-provider-${index}`, - title: `test-title-${index}`, - }); - loadSelector(courseCard.relatedPrograms, [ - programData(0), - programData(1), - programData(2), - programData(3), - programData(4), - ]); - }); - it('returns a card selector based on relatedPrograms cardSimpleSelector', () => { - expect(simpleSelector).toEqual(cardSimpleSelectors.relatedPrograms); - }); - it('passes [bannerImgSrc, logoImgSrc] for each program', () => { - selected.list.forEach((row, i) => { - expect(row.bannerImgSrc).toEqual(testData[i].bannerImgSrc); - expect(row.logoImgSrc).toEqual(testData[i].logoImgSrc); - }); - }); - it('passes [numberOfCourses, programType, programUrl, provider, title] for each', () => { - selected.list.forEach((row, i) => { - expect(row.numberOfCourses).toEqual(testData[i].numberOfCourses); - expect(row.programType).toEqual(testData[i].programType); - expect(row.programUrl).toEqual(testData[i].programUrl); - expect(row.provider).toEqual(testData[i].provider); - expect(row.title).toEqual(testData[i].title); - }); - }); - it('passes number of programs a length', () => { - expect(selected.length).toEqual(testData.length); - }); - }); - }); -}); diff --git a/src/data/redux/app/selectors/currentList.js b/src/data/redux/app/selectors/currentList.js deleted file mode 100644 index 98e1cb5c5..000000000 --- a/src/data/redux/app/selectors/currentList.js +++ /dev/null @@ -1,60 +0,0 @@ -import { StrictDict } from '../../../../utils'; -import { FilterKeys, SortKeys } from '../../../../data/constants/app'; - -import simpleSelectors from './simpleSelectors'; -import * as module from './currentList'; - -export const sortFn = (transform, { reverse }) => (v1, v2) => { - const [a, b] = [v1, v2].map(transform); - if (a === b) { - return 0; - } - return ((a > b) ? 1 : -1) * (reverse ? -1 : 1); -}; - -export const courseFilters = StrictDict({ - [FilterKeys.notEnrolled]: (course) => !course.enrollment.isEnrolled, - [FilterKeys.done]: (course) => course.courseRun !== null && course.courseRun.isArchived, - [FilterKeys.upgraded]: (course) => course.enrollment.isVerified, - [FilterKeys.inProgress]: (course) => course.enrollment.hasStarted, - [FilterKeys.notStarted]: (course) => !course.enrollment.hasStarted, -}); - -export const transforms = StrictDict({ - [SortKeys.enrolled]: ({ enrollment }) => new Date(enrollment.lastEnrolled), - [SortKeys.title]: ({ course }) => course.courseName.toLowerCase(), -}); - -export const courseFilterFn = filters => (filters.length - ? course => filters.reduce((match, filter) => match && courseFilters[filter](course), true) - : () => true); - -export const currentList = (allCourses, { - sortBy, - filters, -}) => allCourses - .filter(module.courseFilterFn(filters)) - .sort(module.sortFn(transforms[sortBy], { reverse: sortBy === SortKeys.enrolled })); - -export const visibleList = (state, { - sortBy, - filters, - pageSize, -}) => { - const courses = Object.values(simpleSelectors.courseData(state)); - const list = module.currentList(courses, { sortBy, filters }); - const pageNumber = simpleSelectors.pageNumber(state); - - if (pageSize === 0) { - return { - visible: list, - numPages: 1, - }; - } - return { - visibleList: list.slice((pageNumber - 1) * pageSize, pageNumber * pageSize), - numPages: Math.ceil(list.length / pageSize), - }; -}; - -export default visibleList; diff --git a/src/data/redux/app/selectors/currentList.test.js b/src/data/redux/app/selectors/currentList.test.js deleted file mode 100644 index 0da12c223..000000000 --- a/src/data/redux/app/selectors/currentList.test.js +++ /dev/null @@ -1,187 +0,0 @@ -import { keyStore } from '@src/utils'; -import { FilterKeys, SortKeys } from '@src/data/constants/app'; -import simpleSelectors from './simpleSelectors'; -import * as module from './currentList'; - -jest.mock('./simpleSelectors', () => ({ - __esModule: true, - default: { - courseData: jest.fn(), - pageNumber: jest.fn(), - }, -})); - -const { - sortFn, - courseFilters, - transforms, - courseFilterFn, - currentList, - visibleList, -} = module; - -const moduleKeys = keyStore(module); - -const testDate = '2000-10-10'; -const testString = 'test-STRING'; - -describe('courseList selector module', () => { - describe('utilities', () => { - describe('sortFn', () => { - it('performs comparison sort after running both values through transform', () => { - const transform = ({ val }) => val; - expect(sortFn(transform, { reverse: false })({ val: 2 }, { val: 1 })).toEqual(1); - expect(sortFn(transform, { reverse: false })({ val: 1 }, { val: 1 })).toEqual(0); - expect(sortFn(transform, { reverse: false })({ val: 1 }, { val: 2 })).toEqual(-1); - expect(sortFn(transform, { reverse: true })({ val: 2 }, { val: 1 })).toEqual(-1); - expect(sortFn(transform, { reverse: true })({ val: 1 }, { val: 1 })).toEqual(0); - expect(sortFn(transform, { reverse: true })({ val: 1 }, { val: 2 })).toEqual(1); - }); - }); - describe('courseFilters', () => { - let filterFn; - test('notEnrolled returns true iff course is not enrolled', () => { - filterFn = courseFilters[FilterKeys.notEnrolled]; - expect(filterFn({ enrollment: { isEnrolled: true } })).toEqual(false); - expect(filterFn({ enrollment: { isEnrolled: false } })).toEqual(true); - }); - test('done returns true iff learner has finished course', () => { - filterFn = courseFilters[FilterKeys.done]; - expect(filterFn({ courseRun: null })).toEqual(false); - expect(filterFn({ courseRun: { isArchived: true } })).toEqual(true); - expect(filterFn({ courseRun: { isArchived: false } })).toEqual(false); - }); - test('upgraded returns true if learner is verified', () => { - filterFn = courseFilters[FilterKeys.upgraded]; - expect(filterFn({ enrollment: { isVerified: true } })).toEqual(true); - expect(filterFn({ enrollment: { isVerified: false } })).toEqual(false); - }); - test('inProgress returns true iff learner has started course', () => { - filterFn = courseFilters[FilterKeys.inProgress]; - expect(filterFn({ enrollment: { hasStarted: true } })).toEqual(true); - expect(filterFn({ enrollment: { hasStarted: false } })).toEqual(false); - }); - test('notStarted returns true iff learner has not started course', () => { - filterFn = courseFilters[FilterKeys.notStarted]; - expect(filterFn({ enrollment: { hasStarted: true } })).toEqual(false); - expect(filterFn({ enrollment: { hasStarted: false } })).toEqual(true); - }); - }); - describe('transforms', () => { - test('enrolled transform returns date transform of enrollment.lastEnrolled', () => { - const data = { enrollment: { lastEnrolled: testDate } }; - expect(transforms[SortKeys.enrolled](data)).toEqual(new Date(testDate)); - }); - test('title transform returns lowercase transform of course.courseName', () => { - const data = { course: { courseName: testString } }; - expect(transforms[SortKeys.title](data)).toEqual(testString.toLowerCase()); - }); - }); - describe('courseFilterFn', () => { - it('returns always-true filter if filter list is empty', () => { - expect(courseFilterFn([])()).toEqual(true); - }); - it('takes filters and then course, and returns true if course matches all filters', () => { - const filters = [FilterKeys.inProgress, FilterKeys.upgraded]; - const inProgressSpy = jest.spyOn(courseFilters, FilterKeys.inProgress); - const upgradedSpy = jest.spyOn(courseFilters, FilterKeys.upgraded); - inProgressSpy.mockImplementation(v => v > 3); - upgradedSpy.mockImplementation(v => v > 5); - - expect(courseFilterFn(filters)(6)).toEqual(true); - expect(courseFilterFn(filters)(5)).toEqual(false); - upgradedSpy.mockImplementation(v => v > 2); - expect(courseFilterFn(filters)(3)).toEqual(false); - - inProgressSpy.mockRestore(); - upgradedSpy.mockRestore(); - }); - }); - describe('currentList selector', () => { - it('returns passed courses filtered and sorted', () => { - const sortSpy = jest.spyOn(module, moduleKeys.sortFn); - const filterSpy = jest.spyOn(module, moduleKeys.courseFilterFn); - filterSpy.mockReturnValue(({ val }) => val > 0); - sortSpy.mockReturnValue((v1, v2) => { - const [a, b] = [v1, v2].map(({ val }) => val); - if (a === b) { - return 0; - } - return (a > b) ? 1 : -1; - }); - const testCourses = { - empty: { val: 0 }, - v1: { val: 1 }, - v2: { val: 2 }, - v3: { val: 3 }, - }; - const { - empty, - v1, - v2, - v3, - } = testCourses; - let sortBy = SortKeys.enrolled; - const testFilters = [1, 2, 3]; - expect(currentList( - [empty, v2, v1, empty, empty, v3, empty], - { sortBy, filters: testFilters }, - )).toEqual([v1, v2, v3]); - expect(sortSpy).toHaveBeenCalledWith(transforms[sortBy], { reverse: true }); - expect(filterSpy).toHaveBeenCalledWith(testFilters); - - sortSpy.mockClear(); - sortBy = SortKeys.title; - expect(currentList( - [empty, v2, v1, empty, empty, v3, empty], - { sortBy, filters: testFilters }, - )).toEqual([v1, v2, v3]); - expect(sortSpy).toHaveBeenCalledWith(transforms[sortBy], { reverse: false }); - expect(filterSpy).toHaveBeenCalledWith(testFilters); - sortSpy.mockRestore(); - filterSpy.mockRestore(); - }); - }); - }); - describe('visibleList selector', () => { - let listSpy; - let out; - const pageSize = 2; - const pageNumber = 3; - const testState = { some: 'state' }; - const sortBy = SortKeys.enrolled; - const testFilters = [1, 2, 3]; - const testCourseData = { test: 'course-data' }; - const testList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; - beforeEach(() => { - listSpy = jest.spyOn(module, moduleKeys.currentList); - listSpy.mockReturnValue(testList); - simpleSelectors.courseData.mockReturnValue(testCourseData); - simpleSelectors.pageNumber.mockReturnValue(pageNumber); - out = visibleList(testState, { sortBy, filters: testFilters, pageSize }); - }); - afterEach(() => { - listSpy.mockRestore(); - }); - it('loads currentList from courseData values and passed sortBy and filters values', () => { - expect(simpleSelectors.courseData).toHaveBeenCalledWith(testState); - expect(simpleSelectors.pageNumber).toHaveBeenCalledWith(testState); - expect(listSpy).toHaveBeenCalledWith( - Object.values(testCourseData), - { sortBy, filters: testFilters }, - ); - }); - it('returns visible page based on passed page size and stored pageNumber', () => { - // page 3, 2 per page. [0 1] [2 3] [4 5] ... - expect(out.visibleList).toEqual([testList[4], testList[5]]); - }); - it('returns number of pages based on page size and list length', () => { - expect(out.numPages).toEqual(6); - }); - it('disable pagination if page size is 0', () => { - out = visibleList(testState, { sortBy, filters: testFilters, pageSize: 0 }); - expect(out.visible).toEqual(testList); - expect(out.numPages).toEqual(1); - }); - }); -}); diff --git a/src/data/redux/app/selectors/index.js b/src/data/redux/app/selectors/index.js deleted file mode 100644 index 914ab5625..000000000 --- a/src/data/redux/app/selectors/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictDict } from '../../../../utils'; - -import simpleSelectors from './simpleSelectors'; -import appSelectors from './appSelectors'; -import courseCard from './courseCard'; -import currentList from './currentList'; - -export default StrictDict({ - ...simpleSelectors, - ...appSelectors, - courseCard, - currentList, -}); diff --git a/src/data/redux/app/selectors/simpleSelectors.js b/src/data/redux/app/selectors/simpleSelectors.js deleted file mode 100644 index 3e12a0b35..000000000 --- a/src/data/redux/app/selectors/simpleSelectors.js +++ /dev/null @@ -1,38 +0,0 @@ -import { createSelector } from 'reselect'; -import { StrictDict } from '../../../../utils'; - -import * as module from './simpleSelectors'; - -export const appSelector = (state) => state.app; -const mkSimpleSelector = (cb) => createSelector([module.appSelector], cb); - -// top-level app data selectors -export const simpleSelectors = StrictDict({ - courseData: mkSimpleSelector(app => app.courseData), - platformSettings: mkSimpleSelector(app => app.platformSettings), - suggestedCourses: mkSimpleSelector(app => app.suggestedCourses), - emailConfirmation: mkSimpleSelector(app => app.emailConfirmation), - enterpriseDashboard: mkSimpleSelector(app => app.enterpriseDashboard || {}), - selectSessionModal: mkSimpleSelector(app => app.selectSessionModal), - pageNumber: mkSimpleSelector(app => app.pageNumber), - filters: mkSimpleSelector(app => app.filters), - socialShareSettings: mkSimpleSelector(app => app.socialShareSettings), -}); - -export const cardSimpleSelectors = StrictDict({ - certificate: ({ certificate }) => certificate, - course: ({ course }) => course, - courseProvider: ({ courseProvider }) => courseProvider, - courseRun: ({ courseRun }) => courseRun, - credit: ({ credit }) => credit, - enrollment: ({ enrollment }) => enrollment, - entitlement: ({ entitlement }) => entitlement, - gradeData: ({ gradeData }) => gradeData, - relatedPrograms: ({ programs: { relatedPrograms } }) => relatedPrograms, -}); - -export const mkCardSelector = (simpleSelector, selector) => (state, cardId) => ( - selector(simpleSelector(module.simpleSelectors.courseData(state)[cardId])) -); - -export default simpleSelectors; diff --git a/src/data/redux/app/selectors/simpleSelectors.test.js b/src/data/redux/app/selectors/simpleSelectors.test.js deleted file mode 100644 index d6db20517..000000000 --- a/src/data/redux/app/selectors/simpleSelectors.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import { keyStore } from '@src/utils'; -import * as module from './simpleSelectors'; - -const { - appSelector, - simpleSelectors, - cardSimpleSelectors, - mkCardSelector, -} = module; - -let keys; -let testData; - -let testState; -const testString = 'test-STRING'; -const testCardId = 'testCARD-id'; - -describe('app simple selectors', () => { - describe('base app selector', () => { - }); - describe('simple selectors', () => { - keys = keyStore(simpleSelectors); - test.each([ - keys.courseData, - keys.platformSettings, - keys.suggestedCourses, - keys.emailConfirmation, - keys.enterpriseDashboard, - keys.selectSessionModal, - keys.pageNumber, - keys.socialShareSettings, - keys.filters, - ])('%s app simple selector forwards corresponding data from app store', (key) => { - testState = { app: { [key]: testString, otherField: 'fake string' } }; - const { preSelectors, cb } = simpleSelectors[key]; - expect(preSelectors).toEqual([appSelector]); - expect(cb(testState.app)).toEqual(testString); - }); - describe('cardSimpleSelectors', () => { - keys = keyStore(cardSimpleSelectors); - test.each([ - keys.certificate, - keys.course, - keys.courseProvider, - keys.courseRun, - keys.credit, - keys.enrollment, - keys.entitlement, - keys.gradeData, - ])('%s card simple selector forwards corresponding data from passed object', (key) => { - testState = { [key]: testString }; - expect(cardSimpleSelectors[key](testState)).toEqual(testString); - }); - test('relatedPrograms simple selector forwards relatedPrograms from programs obj', () => { - expect( - cardSimpleSelectors.relatedPrograms({ programs: { relatedPrograms: testString } }), - ).toEqual(testString); - }); - }); - describe('mkCardSelector util', () => { - it('takes [card simpleSelector, selector] and creates card selector from cardData', () => { - const selector = (data) => ({ selector: data }); - const simpleSelector = (data) => ({ simpleSelector: data }); - testData = { some: 'test data' }; - const oldCourseData = simpleSelectors.courseData; - simpleSelectors.courseData = jest.fn().mockReturnValueOnce({ [testCardId]: testData }); - expect(mkCardSelector(simpleSelector, selector)(testState, testCardId)).toEqual( - selector(simpleSelector(testData)), - ); - expect(simpleSelectors.courseData).toHaveBeenCalledWith(testState); - simpleSelectors.courseData = oldCourseData; - }); - }); - }); -}); diff --git a/src/data/redux/hooks/app.js b/src/data/redux/hooks/app.js deleted file mode 100644 index 7eca9056e..000000000 --- a/src/data/redux/hooks/app.js +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -import { useSelector, useDispatch } from 'react-redux'; - -import * as redux from '../../../data/redux'; -import * as module from './app'; - -const selectors = redux.selectors.app; -const actions = redux.actions.app; - -/** Simple Selectors **/ -export const usePageNumber = () => useSelector(selectors.pageNumber); -export const useFilters = () => useSelector(selectors.filters); -export const useEmailConfirmationData = () => useSelector(selectors.emailConfirmation); -export const useEnterpriseDashboardData = () => useSelector(selectors.enterpriseDashboard); -export const usePlatformSettingsData = () => useSelector(selectors.platformSettings); -export const useSelectSessionModalData = () => useSelector(selectors.selectSessionModal); -export const useSocialShareSettings = () => useSelector(selectors.socialShareSettings); - -/** global-level meta-selectors **/ -export const useHasCourses = () => useSelector(selectors.hasCourses); -export const useCurrentCourseList = (opts) => useSelector( - state => selectors.currentList(state, opts), -); -export const useShowSelectSessionModal = () => useSelector(selectors.showSelectSessionModal); - -export const useCourseCardData = (selector) => (cardId) => useSelector( - (state) => selector(state, cardId), -); -/** Course Card selectors **/ -const { courseCard } = selectors; -export const useCardCertificateData = useCourseCardData(courseCard.certificate); -export const useCardCourseData = useCourseCardData(courseCard.course); -export const useCardCourseRunData = useCourseCardData(courseCard.courseRun); -export const useCardCreditData = useCourseCardData(courseCard.credit); -export const useCardEnrollmentData = useCourseCardData(courseCard.enrollment); -export const useCardEntitlementData = useCourseCardData(courseCard.entitlement); -export const useCardGradeData = useCourseCardData(courseCard.gradeData); -export const useCardProviderData = useCourseCardData(courseCard.courseProvider); -export const useCardRelatedProgramsData = useCourseCardData(courseCard.relatedPrograms); - -export const useCardSocialSettingsData = (cardId) => { - const socialShareSettings = module.useSocialShareSettings(); - const { socialShareUrl } = module.useCardCourseData(cardId); - const defaultSettings = { isEnabled: false, shareUrl: '' }; - - if (!socialShareSettings) { - return { facebook: defaultSettings, twitter: defaultSettings }; - } - const { facebook, twitter } = socialShareSettings; - const loadSettings = (target) => ({ - isEnabled: target.isEnabled, - shareUrl: `${socialShareUrl}?${target.utmParams}`, - }); - return { facebook: loadSettings(facebook), twitter: loadSettings(twitter) }; -}; - -export const useCardExecEdTrackingParam = (cardId) => { - const { isExecEd2UCourse } = module.useCardEnrollmentData(cardId); - const { authOrgId } = module.useEnterpriseDashboardData(cardId); - return isExecEd2UCourse ? `?org_id=${authOrgId}` : ''; -}; - -/** Events **/ -export const useUpdateSelectSessionModalCallback = (cardId) => { - const dispatch = useDispatch(); - return () => dispatch(actions.updateSelectSessionModal(cardId)); -}; - -export const useTrackCourseEvent = (tracker, cardId, ...args) => { - const { courseId } = module.useCardCourseRunData(cardId); - return (e) => tracker(courseId, ...args)(e); -}; - -export const useSetPageNumber = () => { - const dispatch = useDispatch(); - return (value) => dispatch(actions.setPageNumber(value)); -}; - -export const useSetFilters = () => { - const dispatch = useDispatch(); - return (value) => dispatch(actions.setFilters(value)); -}; - -export const useAddFilter = () => { - const dispatch = useDispatch(); - return (value) => dispatch(actions.addFilter(value)); -}; - -export const useRemoveFilter = () => { - const dispatch = useDispatch(); - return (value) => dispatch(actions.removeFilter(value)); -}; - -export const useClearFilters = () => { - const dispatch = useDispatch(); - return (value) => dispatch(actions.clearFilters(value)); -}; - -export const useLoadData = () => { - const dispatch = useDispatch(); - return ({ courses, ...globalData }) => { - dispatch(actions.setPageNumber(1)); - dispatch(actions.loadGlobalData(globalData)); - dispatch(actions.loadCourses({ courses })); - }; -}; diff --git a/src/data/redux/hooks/index.js b/src/data/redux/hooks/index.js deleted file mode 100644 index ba6f57432..000000000 --- a/src/data/redux/hooks/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from './app'; -export * from './requests'; diff --git a/src/data/redux/hooks/requests.js b/src/data/redux/hooks/requests.js deleted file mode 100644 index ec1a38e69..000000000 --- a/src/data/redux/hooks/requests.js +++ /dev/null @@ -1,47 +0,0 @@ -import { useSelector, useDispatch } from 'react-redux'; - -import * as redux from '../../../data/redux'; -import * as module from './requests'; - -const selectors = redux.selectors.requests; -const actions = redux.actions.requests; - -export const statusSelector = selector => (requestName) => useSelector(selector(requestName)); -export const useRequestIsPending = module.statusSelector(selectors.isPending); -export const useRequestIsFailed = module.statusSelector(selectors.isFailed); -export const useRequestIsCompleted = module.statusSelector(selectors.isCompleted); -export const useRequestIsInactive = module.statusSelector(selectors.isInactive); -export const useRequestError = module.statusSelector(selectors.error); -export const useRequestErrorCode = module.statusSelector(selectors.errorCode); -export const useRequestErrorStatus = module.statusSelector(selectors.errorStatus); -export const useRequestData = module.statusSelector(selectors.data); - -export const useMakeNetworkRequest = () => { - const dispatch = useDispatch(); - return ({ - requestKey, - promise, - onSuccess, - onFailure, - }) => { - dispatch(actions.startRequest({ requestKey })); - return promise.then((response) => { - if (onSuccess) { - onSuccess(response); - } - dispatch(actions.completeRequest({ requestKey, response })); - }).catch((error) => { - if (onFailure) { - onFailure(error); - } - dispatch(actions.failRequest({ requestKey, error })); - }); - }; -}; - -export const useClearRequest = () => { - const dispatch = useDispatch(); - return (requestKey) => { - dispatch(actions.clearRequest({ requestKey })); - }; -}; diff --git a/src/data/redux/index.js b/src/data/redux/index.js deleted file mode 100644 index 9f96b55bc..000000000 --- a/src/data/redux/index.js +++ /dev/null @@ -1,37 +0,0 @@ -import { combineReducers } from 'redux'; - -import { StrictDict } from '../../utils'; - -import * as app from './app'; -import * as requests from './requests'; - -const modules = { - app, - requests, -}; - -/** - * Extracts keys from the modules object and the provided propName parameter to locate the - * corresponding object for that propName. - * Example: moduleProps('reducer') will return an aggregated object containing the reducer for each module - * - * @param {string} propName Used to locate the prop in each module - * @returns {object} Aggregated values for the provided propName - */ -const moduleProps = (propName) => Object.keys(modules).reduce( - (obj, moduleKey) => { - const value = modules[moduleKey][propName]; - return value ? { ...obj, [moduleKey]: value } : obj; - }, - {}, -); - -const rootReducer = combineReducers(moduleProps('reducer')); - -const actions = StrictDict(moduleProps('actions')); - -const selectors = StrictDict(moduleProps('selectors')); - -export { actions, selectors }; - -export default rootReducer; diff --git a/src/data/redux/requests/index.js b/src/data/redux/requests/index.js deleted file mode 100644 index 8abd5f91d..000000000 --- a/src/data/redux/requests/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { actions, reducer } from './reducer'; -export { default as selectors } from './selectors'; diff --git a/src/data/redux/requests/reducer.js b/src/data/redux/requests/reducer.js deleted file mode 100644 index 4cddb204f..000000000 --- a/src/data/redux/requests/reducer.js +++ /dev/null @@ -1,53 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; - -import { StrictDict } from '../../../utils'; - -import { RequestStates, RequestKeys } from '../../../data/constants/requests'; - -const initialState = { - [RequestKeys.initialize]: { status: RequestStates.inactive }, - [RequestKeys.refreshList]: { status: RequestStates.inactive }, - [RequestKeys.enrollEntitlementSession]: { status: RequestStates.inactive }, - [RequestKeys.leaveEntitlementSession]: { status: RequestStates.inactive }, - [RequestKeys.masquerade]: { status: RequestStates.inactive }, -}; - -const requests = createSlice({ - name: 'requests', - initialState, - reducers: { - startRequest: (state, { payload }) => ({ - ...state, - [payload.requestKey]: { - status: RequestStates.pending, - }, - }), - completeRequest: (state, { payload }) => ({ - ...state, - [payload.requestKey]: { - status: RequestStates.completed, - response: payload.response, - }, - }), - failRequest: (state, { payload }) => ({ - ...state, - [payload.requestKey]: { - status: RequestStates.failed, - error: payload.error, - }, - }), - clearRequest: (state, { payload }) => ({ - ...state, - [payload.requestKey]: {}, - }), - }, -}); - -const actions = StrictDict(requests.actions); -const { reducer } = requests; - -export { - actions, - reducer, - initialState, -}; diff --git a/src/data/redux/requests/reducer.test.js b/src/data/redux/requests/reducer.test.js deleted file mode 100644 index 34d5a6625..000000000 --- a/src/data/redux/requests/reducer.test.js +++ /dev/null @@ -1,62 +0,0 @@ -import { RequestStates } from '@src/data/constants/requests'; -import { initialState, actions, reducer } from './reducer'; - -const testingState = { - ...initialState, - arbitraryField: 'arbitrary', -}; - -describe('requests reducer', () => { - it('has initial state', () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - - const testValue = 'roll for initiative'; - const testKey = 'test-key'; - describe('handling actions', () => { - describe('startRequest', () => { - it('adds a pending status for the given key', () => { - expect(reducer( - testingState, - actions.startRequest({ requestKey: testKey }), - )).toEqual({ - ...testingState, - [testKey]: { status: RequestStates.pending }, - }); - }); - }); - describe('completeRequest', () => { - it('adds a completed status with passed response', () => { - expect(reducer( - testingState, - actions.completeRequest({ requestKey: testKey, response: testValue }), - )).toEqual({ - ...testingState, - [testKey]: { status: RequestStates.completed, response: testValue }, - }); - }); - }); - describe('failRequest', () => { - it('adds a failed status with passed error', () => { - expect(reducer( - testingState, - actions.failRequest({ requestKey: testKey, error: testValue }), - )).toEqual({ - ...testingState, - [testKey]: { status: RequestStates.failed, error: testValue }, - }); - }); - }); - describe('clearRequest', () => { - it('cleanup status and error', () => { - expect(reducer( - testingState, - actions.clearRequest({ requestKey: testKey, error: testValue }), - )).toEqual({ - ...testingState, - [testKey]: {}, - }); - }); - }); - }); -}); diff --git a/src/data/redux/requests/selectors.js b/src/data/redux/requests/selectors.js deleted file mode 100644 index cfcb29e85..000000000 --- a/src/data/redux/requests/selectors.js +++ /dev/null @@ -1,28 +0,0 @@ -import { StrictDict } from '../../../utils'; -import { RequestStates } from '../../../data/constants/requests'; - -export const requestStatus = (state, { requestKey }) => state.requests[requestKey]; - -const statusSelector = (fn) => (requestKey) => (state) => fn(state.requests[requestKey]); - -export const isInactive = ({ status }) => status === RequestStates.inactive; -export const isPending = ({ status }) => status === RequestStates.pending; -export const isCompleted = ({ status }) => status === RequestStates.completed; -export const isFailed = ({ status }) => status === RequestStates.failed; -export const error = (request) => request.error; -export const errorStatus = (request) => request.error?.response?.status; -export const errorCode = (request) => request.error?.response?.data; - -export const data = (request) => request.data; - -export default StrictDict({ - requestStatus, - isInactive: statusSelector(isInactive), - isPending: statusSelector(isPending), - isCompleted: statusSelector(isCompleted), - isFailed: statusSelector(isFailed), - error: statusSelector(error), - errorCode: statusSelector(errorCode), - errorStatus: statusSelector(errorStatus), - data: statusSelector(data), -}); diff --git a/src/data/redux/requests/selectors.test.js b/src/data/redux/requests/selectors.test.js deleted file mode 100644 index 85d123e14..000000000 --- a/src/data/redux/requests/selectors.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import { RequestStates } from '@src/data/constants/requests'; - -import selectors from './selectors'; - -const requestKey = 'my-test-request-key'; -const requestData = { some: 'request-data' }; -const inactiveRequest = { status: RequestStates.inactive, some: 'request-data' }; -const pendingRequest = { status: RequestStates.pending, some: 'request-data' }; -const completedRequest = { status: RequestStates.completed, some: 'request-data' }; -const failedRequest = { status: RequestStates.failed, some: 'request-data' }; - -const testValue = 'my-test-value'; - -const testErrorValue = { - response: { - status: 500, - data: 'my-test-error', - }, -}; - -const testState = { - requests: { - [requestKey]: requestData, - }, -}; -const mockUseSelector = (selector, state) => selector(state); -const genRequests = (request) => ({ - requests: { [requestKey]: request }, -}); -const select = (selector, request) => ( - mockUseSelector(selector(requestKey), genRequests(request)) -); -describe('requests selectors unit tests', () => { - test('requestStatus returns data associated with given key', () => { - expect(selectors.requestStatus(testState, { requestKey })).toEqual(requestData); - }); - const testStatusSelector = (selector, matchingRequest) => { - expect(mockUseSelector(selector(requestKey), testState)).toEqual(false); - expect(mockUseSelector( - selector(requestKey), - { requests: { [requestKey]: matchingRequest } }, - )).toEqual(true); - }; - test('isInactive returns true iff the given request is inactive', () => { - testStatusSelector(selectors.isInactive, inactiveRequest); - }); - test('isPending returns true iff the given request is pending', () => { - testStatusSelector(selectors.isPending, pendingRequest); - }); - test('isCompleted returns true iff the given request is completed', () => { - testStatusSelector(selectors.isCompleted, completedRequest); - }); - test('isFailed returns true iff the given request is failed', () => { - testStatusSelector(selectors.isFailed, failedRequest); - }); - test('error returns the error from the request', () => { - expect(select(selectors.error, { error: testValue })).toEqual(testValue); - }); - test('errorStatus returns the error response status', () => { - expect(select(selectors.errorStatus, {})).toEqual(undefined); - expect(select(selectors.errorStatus, { error: {} })).toEqual(undefined); - expect(select(selectors.errorStatus, { error: { response: {} } })).toEqual(undefined); - expect(select(selectors.errorStatus, { error: { response: { status: testValue } } })) - .toEqual(testValue); - expect(select(selectors.errorStatus, { error: testErrorValue })).toEqual( - testErrorValue.response.status, - ); - }); - test('errorCode returns the error response data', () => { - expect(select(selectors.errorCode, {})).toEqual(undefined); - expect(select(selectors.errorCode, { error: {} })).toEqual(undefined); - expect(select(selectors.errorCode, { error: { response: {} } })).toEqual(undefined); - expect(select(selectors.errorCode, { error: { response: { data: testValue } } })) - .toEqual(testValue); - expect(select(selectors.errorCode, { error: testErrorValue })).toEqual( - testErrorValue.response.data, - ); - }); - test('data reurns the request data', () => { - expect(select(selectors.data, { data: testValue })).toEqual(testValue); - }); -}); diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js deleted file mode 100644 index 088e2b27e..000000000 --- a/src/data/services/lms/api.js +++ /dev/null @@ -1,77 +0,0 @@ -import eventNames from '../../../tracking/constants'; -import { - client, - get, - post, - stringifyUrl, -} from './utils'; -import { - apiKeys, - unenrollmentAction, - enableEmailsAction, -} from './constants'; -import urls from './urls'; -import * as module from './api'; - -/********************************************************************************* - * GET Actions - *********************************************************************************/ -export const initializeList = ({ user } = {}) => get( - stringifyUrl(urls.getInitApiUrl(), { [apiKeys.user]: user }), -); - -export const updateEntitlementEnrollment = ({ uuid, courseId }) => post( - urls.entitlementEnrollment(uuid), - { [apiKeys.courseRunId]: courseId }, -); - -export const deleteEntitlementEnrollment = ({ uuid, isRefundable }) => client() - .delete( - stringifyUrl( - urls.entitlementEnrollment(uuid), - { [apiKeys.isRefund]: isRefundable }, - ), - ); - -export const updateEmailSettings = ({ courseId, enable }) => post( - urls.updateEmailSettings(), - { [apiKeys.courseId]: courseId, ...(enable && enableEmailsAction) }, -); - -export const unenrollFromCourse = ({ courseId }) => post( - urls.courseUnenroll(), - { [apiKeys.courseId]: courseId, ...unenrollmentAction }, -); - -export const logEvent = ({ eventName, data, courseId }) => post(urls.event(), { - courserun_key: courseId, - event_type: eventName, - page: window.location.href, - event: JSON.stringify(data), -}); - -export const logShare = ({ courseId, site }) => module.logEvent({ - eventName: eventNames.shareClicked, - courseId, - data: { - course_id: courseId, - social_media_site: site, - location: 'dashboard', - }, -}); - -export const createCreditRequest = ({ providerId, courseId, username }) => post( - urls.creditRequestUrl(providerId), - { course_key: courseId, username }, -); - -export default { - initializeList, - unenrollFromCourse, - updateEmailSettings, - updateEntitlementEnrollment, - deleteEntitlementEnrollment, - logEvent, - logShare, - createCreditRequest, -}; diff --git a/src/data/services/lms/api.test.js b/src/data/services/lms/api.test.js deleted file mode 100644 index ba0b24257..000000000 --- a/src/data/services/lms/api.test.js +++ /dev/null @@ -1,156 +0,0 @@ -import { mockLocation } from '@src/testUtils'; -import { keyStore } from '@src/utils'; -import { eventNames } from '@src/tracking/constants'; -import * as api from './api'; -import * as utils from './utils'; -import urls from './urls'; -import { - apiKeys, - unenrollmentAction, - enableEmailsAction, -} from './constants'; - -jest.mock('./utils', () => { - const deleteFn = (...args) => ({ delete: args }); - return { - client: () => ({ delete: deleteFn }), - delete: deleteFn, - get: (...args) => ({ get: args }), - post: jest.fn((...args) => ({ post: args })), - stringifyUrl: (...args) => ({ stringifyUrl: args }), - }; -}); - -const testUser = 'test-user'; -const testUuid = 'test-UUID'; -const courseId = 'TEST-course-ID'; -const isRefundable = 'test-is-refundable'; - -const moduleKeys = keyStore(api); - -describe('lms api methods', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - test('keys identical to module', async () => { - const mod = await import('./api'); - const { default: defaultApi, ...rest } = mod; - expect(Object.keys(rest).sort()).toMatchObject(Object.keys(defaultApi).sort()); - }); - describe('initializeList', () => { - test('calls get with the correct url and user', () => { - const userArg = { - [apiKeys.user]: testUser, - }; - expect(api.initializeList(userArg)).toEqual( - utils.get(utils.stringifyUrl(urls.getInitApiUrl(), userArg)), - ); - }); - }); - describe('updateEntitlementEnrollment', () => { - it('calls post on entitlementEnrollment url with uuid and course run ID', () => { - expect( - api.updateEntitlementEnrollment({ uuid: testUuid, courseId }), - ).toEqual( - utils.post( - urls.entitlementEnrollment(testUuid), - { [apiKeys.courseRunId]: courseId }, - ), - ); - }); - }); - describe('deleteEntitlementEnrollment', () => { - it('calls delete on entitlementEnrollment url with uuid and null course run ID', () => { - expect( - api.deleteEntitlementEnrollment({ uuid: testUuid, isRefundable }), - ).toEqual( - utils.client().delete(utils.stringifyUrl( - urls.entitlementEnrollment(testUuid), - { [apiKeys.isRefund]: isRefundable }, - )), - ); - }); - }); - describe('updateEmailSettings', () => { - describe('disable', () => { - it('calls post on updateEmailSettings url with course ID', () => { - expect( - api.updateEmailSettings({ courseId, enable: false }), - ).toEqual( - utils.post(urls.updateEmailSettings(), { [apiKeys.courseId]: courseId }), - ); - }); - }); - describe('enable', () => { - it('calls post on updateEmailSettings url with course ID and enableEmailsAction', () => { - expect( - api.updateEmailSettings({ courseId, enable: true }), - ).toEqual( - utils.post( - urls.updateEmailSettings(), - { [apiKeys.courseId]: courseId, ...enableEmailsAction }, - ), - ); - }); - }); - }); - describe('unenrollFromCourse', () => { - it('calls post on unenrollFromCourse url with courseId and unenrollment action', () => { - expect( - api.unenrollFromCourse({ courseId }), - ).toEqual( - utils.post( - urls.courseUnenroll(), - { [apiKeys.courseId]: courseId, ...unenrollmentAction }, - ), - ); - }); - }); - describe('logging events', () => { - describe('logEvent', () => { - it('posts to event url with event data', () => { - const href = 'test-href'; - const eventName = 'test-event-key'; - const data = { some: 'data' }; - mockLocation(href); - expect( - api.logEvent({ courseId, eventName, data }), - ).toEqual( - utils.post(urls.event(), { - courserun_key: courseId, - event_type: eventName, - page: href, - event: JSON.stringify(data), - }), - ); - }); - }); - describe('logged events', () => { - const logEvent = (args) => ({ logEvent: args }); - beforeEach(() => { - jest.spyOn(api, moduleKeys.logEvent).mockImplementation(logEvent); - }); - test('logShare sends share clicke vent with course id, side and location', () => { - const site = 'test-site'; - expect(api.logShare({ courseId, site })).toEqual(logEvent({ - eventName: eventNames.shareClicked, - courseId, - data: { course_id: courseId, social_media_site: site, location: 'dashboard' }, - })); - }); - }); - }); - describe('credit requests', () => { - describe('createCreditRequest', () => { - const providerId = 'test-provider-id'; - const username = 'test-username'; - it('posts course ID and username to credit request url', () => { - api.createCreditRequest({ providerId, courseId, username }); - expect(utils.post).toHaveBeenCalledWith( - urls.creditRequestUrl(providerId), - { course_key: courseId, username }, - ); - }); - }); - }); -}); diff --git a/src/data/services/lms/api.test.tsx b/src/data/services/lms/api.test.tsx new file mode 100644 index 000000000..b68fe540b --- /dev/null +++ b/src/data/services/lms/api.test.tsx @@ -0,0 +1,285 @@ +import { getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import urls from '@src/data/services/lms/urls'; +import { stringifyUrl } from '@src/data/services/lms/utils'; +import { + initializeList, + unenrollFromCourse, + updateEntitlementEnrollment, + deleteEntitlementEnrollment, + updateEmailSettings, + logEvent, + logShare, + createCreditRequest, + sendConfirmEmail, +} from './api'; + +// Mock dependencies +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + getAuthenticatedHttpClient: jest.fn(), +})); +jest.mock('@src/data/services/lms/constants', () => ({ + apiKeys: { + user: 'user', + courseId: 'course_id', + courseRunId: 'course_run_id', + isRefund: 'is_refund', + }, + enableEmailsAction: { enable: true }, + unenrollmentAction: { action: 'unenroll' }, +})); +jest.mock('@src/data/services/lms/urls'); +jest.mock('@src/data/services/lms/utils'); +jest.mock('@src/tracking/constants', () => ({ + __esModule: true, + default: { shareClicked: 'share_clicked' }, +})); + +const mockHttpClient = { + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), +}; + +const mockedGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction< + typeof getAuthenticatedHttpClient>; +const mockedStringifyUrl = stringifyUrl as jest.MockedFunction; + +describe('API functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any); + + // Mock urls + (urls as any).getInitApiUrl = jest.fn(() => '/api/init'); + (urls as any).courseUnenroll = jest.fn(() => '/api/unenroll'); + (urls as any).entitlementEnrollment = jest.fn((uuid) => `/api/entitlement/${uuid}`); + (urls as any).updateEmailSettings = jest.fn(() => '/api/email-settings'); + (urls as any).event = jest.fn(() => '/api/event'); + (urls as any).creditRequestUrl = jest.fn((providerId) => `/api/credit/${providerId}`); + + mockedStringifyUrl.mockImplementation((url, params) => { + const paramString = Object.entries(params || {}).map(([key, value]) => `${key}=${value}`).join('&'); + return paramString ? `${url}?${paramString}` : url; + }); + + // Mock window.location + delete (window as any).location; + (window as any).location = { href: 'https://example.com/dashboard' }; + }); + + describe('initializeList', () => { + it('should make GET request to init API with user parameter', async () => { + const mockData = { courses: [], entitlements: [] }; + mockHttpClient.get.mockResolvedValue({ data: mockData }); + + const result = await initializeList('test-user'); + + expect(urls.getInitApiUrl).toHaveBeenCalled(); + expect(stringifyUrl).toHaveBeenCalledWith('/api/init', { user: 'test-user' }); + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/init?user=test-user'); + expect(result).toEqual(mockData); + }); + + it('should handle null user parameter', async () => { + const mockData = { courses: [] }; + mockHttpClient.get.mockResolvedValue({ data: mockData }); + + await initializeList(null); + + expect(stringifyUrl).toHaveBeenCalledWith('/api/init', { user: null }); + }); + }); + + describe('unenrollFromCourse', () => { + it('should make POST request to unenroll from course with FormData', async () => { + const mockResponse = { status: 200 }; + mockHttpClient.post.mockResolvedValue(mockResponse); + const result = await unenrollFromCourse({ courseId: 'course-123' }); + expect(urls.courseUnenroll).toHaveBeenCalled(); + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/unenroll', expect.any(FormData)); + expect(result).toEqual(mockResponse); + }); + }); + + describe('updateEntitlementEnrollment', () => { + it('should make POST request to update entitlement enrollment', async () => { + const mockResponse = { status: 200 }; + mockHttpClient.post.mockResolvedValue(mockResponse); + + const result = await updateEntitlementEnrollment({ + uuid: 'entitlement-uuid', + courseId: 'course-123', + }); + + expect(urls.entitlementEnrollment).toHaveBeenCalledWith('entitlement-uuid'); + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/entitlement/entitlement-uuid', { + course_run_id: 'course-123', + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('deleteEntitlementEnrollment', () => { + it('should make DELETE request with refundable flag', async () => { + const mockResponse = { status: 204 }; + mockHttpClient.delete.mockResolvedValue(mockResponse); + + const result = await deleteEntitlementEnrollment({ + uuid: 'entitlement-uuid', + isRefundable: true, + }); + + expect(urls.entitlementEnrollment).toHaveBeenCalledWith('entitlement-uuid'); + expect(stringifyUrl).toHaveBeenCalledWith('/api/entitlement/entitlement-uuid', { + is_refund: true, + }); + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/entitlement/entitlement-uuid?is_refund=true'); + expect(result).toEqual(mockResponse); + }); + + it('should handle non-refundable deletion', async () => { + const mockResponse = { status: 204 }; + mockHttpClient.delete.mockResolvedValue(mockResponse); + + await deleteEntitlementEnrollment({ + uuid: 'entitlement-uuid', + isRefundable: false, + }); + + expect(stringifyUrl).toHaveBeenCalledWith('/api/entitlement/entitlement-uuid', { + is_refund: false, + }); + }); + }); + + describe('updateEmailSettings', () => { + it('should make POST request to enable email settings', async () => { + const mockResponse = { status: 200 }; + mockHttpClient.post.mockResolvedValue(mockResponse); + + const result = await updateEmailSettings({ + courseId: 'course-123', + enable: true, + }); + + expect(urls.updateEmailSettings).toHaveBeenCalled(); + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/email-settings', { + course_id: 'course-123', + enable: true, + }); + expect(result).toEqual(mockResponse); + }); + + it('should make POST request to disable email settings', async () => { + const mockResponse = { status: 200 }; + mockHttpClient.post.mockResolvedValue(mockResponse); + + const result = await updateEmailSettings({ + courseId: 'course-123', + enable: false, + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/email-settings', { + course_id: 'course-123', + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('logEvent', () => { + it('should make POST request to log event', async () => { + const mockResponse = { status: 200 }; + mockHttpClient.post.mockResolvedValue(mockResponse); + + const eventData = { + eventName: 'test-event', + data: { key: 'value' }, + courseId: 'course-123', + }; + + const result = await logEvent(eventData); + + expect(urls.event).toHaveBeenCalled(); + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/event', { + courserun_key: 'course-123', + event_type: 'test-event', + page: 'https://example.com/dashboard', + event: JSON.stringify({ key: 'value' }), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('logShare', () => { + it('should call logEvent with share event data', async () => { + const mockResponse = { status: 200 }; + mockHttpClient.post.mockResolvedValue(mockResponse); + + const result = await logShare({ + courseId: 'course-123', + site: 'facebook', + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/event', { + courserun_key: 'course-123', + event_type: 'share_clicked', + page: 'https://example.com/dashboard', + event: JSON.stringify({ + course_id: 'course-123', + social_media_site: 'facebook', + location: 'dashboard', + }), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('createCreditRequest', () => { + it('should make POST request to create credit request', async () => { + const mockResponse = { status: 201 }; + mockHttpClient.post.mockResolvedValue(mockResponse); + + const result = await createCreditRequest({ + providerId: 'provider-123', + courseId: 'course-123', + username: 'test-user', + }); + + expect(urls.creditRequestUrl).toHaveBeenCalledWith('provider-123'); + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/credit/provider-123', { + course_key: 'course-123', + username: 'test-user', + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('sendConfirmEmail', () => { + it('should make POST request to send confirmation email', async () => { + const mockResponse = { status: 200 }; + mockHttpClient.post.mockResolvedValue(mockResponse); + + const result = await sendConfirmEmail('https://example.com/send-email'); + + expect(mockHttpClient.post).toHaveBeenCalledWith('https://example.com/send-email'); + expect(result).toEqual(mockResponse); + }); + }); + + describe('Error handling', () => { + it('should propagate network errors', async () => { + const networkError = new Error('Network error'); + mockHttpClient.get.mockRejectedValue(networkError); + + await expect(initializeList('test-user')).rejects.toThrow('Network error'); + }); + + it('should propagate API errors', async () => { + const apiError = { response: { status: 404, data: { error: 'Not found' } } }; + mockHttpClient.post.mockRejectedValue(apiError); + + await expect(unenrollFromCourse({ courseId: 'invalid-course' })).rejects.toEqual(apiError); + }); + }); +}); diff --git a/src/data/services/lms/api.ts b/src/data/services/lms/api.ts new file mode 100644 index 000000000..a04093f09 --- /dev/null +++ b/src/data/services/lms/api.ts @@ -0,0 +1,95 @@ +import { getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { apiKeys, enableEmailsAction, unenrollmentAction } from '@src/data/services/lms/constants'; +import urls from '@src/data/services/lms/urls'; +import { stringifyUrl } from '@src/data/services/lms/utils'; +import eventNames from '@src/tracking/constants'; + +const initializeList = async (user) => { + const { data } = await getAuthenticatedHttpClient().get( + stringifyUrl(urls.getInitApiUrl(), { [apiKeys.user]: user }), + ); + return data; +}; + +const unenrollFromCourse = async ({ courseId }) => { + const url = urls.courseUnenroll(); + const formData = new FormData(); + formData.append(apiKeys.courseId, courseId); + Object.entries(unenrollmentAction).forEach(([key, value]) => { + formData.append(key, value); + }); + const response = await getAuthenticatedHttpClient().post(url, formData); + return response; +}; + +const updateEntitlementEnrollment = async ({ uuid, courseId }) => { + const url = urls.entitlementEnrollment(uuid); + const content = { [apiKeys.courseRunId]: courseId }; + const response = await getAuthenticatedHttpClient().post(url, content); + return response; +}; + +const deleteEntitlementEnrollment = async ({ uuid, isRefundable }) => { + const url = stringifyUrl( + urls.entitlementEnrollment(uuid), + { [apiKeys.isRefund]: isRefundable }, + ); + const response = await getAuthenticatedHttpClient().delete(url); + return response; +}; + +const updateEmailSettings = async ({ courseId, enable }) => { + const url = urls.updateEmailSettings(); + const content = { [apiKeys.courseId]: courseId, ...(enable && enableEmailsAction) }; + const response = await getAuthenticatedHttpClient().post(url, content); + return response; +}; + +const logEvent = async ({ eventName, data, courseId }) => { + const url = urls.event(); + const content = { + courserun_key: courseId, + event_type: eventName, + page: window.location.href, + event: JSON.stringify(data), + }; + const response = await getAuthenticatedHttpClient().post(url, content); + return response; +}; + +const logShare = ({ courseId, site }) => { + const eventData = { + eventName: eventNames.shareClicked, + courseId, + data: { + course_id: courseId, + social_media_site: site, + location: 'dashboard', + }, + }; + return logEvent(eventData); +}; + +const createCreditRequest = async ({ providerId, courseId, username }) => { + const url = urls.creditRequestUrl(providerId); + const content = { course_key: courseId, username }; + const response = await getAuthenticatedHttpClient().post(url, content); + return response; +}; + +const sendConfirmEmail = async (sendEmailUrl: string) => { + const response = await getAuthenticatedHttpClient().post(sendEmailUrl); + return response; +}; + +export { + initializeList, + unenrollFromCourse, + updateEntitlementEnrollment, + deleteEntitlementEnrollment, + updateEmailSettings, + logEvent, + logShare, + createCreditRequest, + sendConfirmEmail, +}; diff --git a/src/data/services/lms/fakeData/courses.js b/src/data/services/lms/fakeData/courses.js deleted file mode 100644 index c1e3f9da1..000000000 --- a/src/data/services/lms/fakeData/courses.js +++ /dev/null @@ -1,828 +0,0 @@ -import { StrictDict } from '../../../../utils'; -import creditVals from '../../../../data/constants/credit'; - -export const providers = StrictDict({ - edx: { name: 'edX Course Provider' }, - mit: { name: 'MIT' }, -}); - -export const relatedPrograms = [ - { - provider: 'HarvardX', - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/course/image/327c8e4f-315a-417b-9857-046dfc90c243-677b97464958.small.jpg', - logoImgSrc: 'https://prod-discovery.edx-cdn.org/organization/certificate_logos/44022f13-20df-4666-9111-cede3e5dc5b6-770e00385e7e.png', - title: 'Relativity in Modern Mechanics', - programUrl: 'www.edx/my-program', - programType: 'MicroBachelors Program', - numberOfCourses: 3, - }, - { - provider: 'University of Maryland', - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - logoImgSrc: 'https://prod-discovery.edx-cdn.org/organization/certificate_logos/b9dc96da-b3fc-45a6-b6b7-b8e12eb79335-ac60112330e3.png', - title: 'Pandering for Modern Professionals', - programUrl: 'www.edx/my-program-2', - programType: 'MicroBachelors Program', - programTypeUrl: 'www.edx/my-program-type', - numberOfCourses: 3, - }, - { - provider: 'HarvardX', - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/course/image/327c8e4f-315a-417b-9857-046dfc90c243-677b97464958.small.jpg', - logoImgSrc: 'https://prod-discovery.edx-cdn.org/organization/certificate_logos/44022f13-20df-4666-9111-cede3e5dc5b6-770e00385e7e.png', - title: 'Relativity in Modern Mechanics', - programUrl: 'www.edx/my-program-3', - programType: 'MicroBachelors Program', - numberOfCourses: 3, - }, - { - provider: 'University of Maryland', - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - logoImgSrc: 'https://prod-discovery.edx-cdn.org/organization/certificate_logos/b9dc96da-b3fc-45a6-b6b7-b8e12eb79335-ac60112330e3.png', - title: 'Pandering for Modern Professionals', - programUrl: 'www.edx/my-program-4', - programType: 'MicroBachelors Program', - programTypeUrl: 'www.edx/my-program-type', - numberOfCourses: 3, - }, -]; - -export const genCardId = (index) => `card-id${index}`; -export const genCourseId = (index) => `course-number${index}-course-id${index}`; -export const genCourseNumber = (index) => `course-number${index}`; -export const genCourseShareUrl = (index) => `home.edx.org?social-share-url/${index}`; -export const genEntitlementUUID = (index) => `entitlement-course-uuid-${index}`; - -const bannerImgSrc = '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg'; - -export const farPastDate = '1900-11-11T00:00:00Z'; -export const pastDate = '2000-11-11T00:00:00Z'; -export const futureDate = '3030-11-11T00:00:00Z'; -export const farFutureDate = '4040-11-11T00:00:00Z'; -export const soonDate = new Date(); -soonDate.setDate(soonDate.getDate() + 60); -export const soonDateStr = soonDate.toDateString(); - -export const globalData = { - emailConfirmation: { - isNeeded: true, - sendEmailUrl: 'sendConfirmation@edx.org', - }, - enterpriseDashboard: { label: 'edX, Inc.', url: '/edx-dashboard' }, - platformSettings: { - supportEmail: 'support@example.com', - billingEmail: 'billing@email.com', - courseSearchUrl: 'edx.com/course-search', - }, - suggestedCourses: [ - { - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - courseName: 'Suggested course 1', - courseUrl: 'www.edx/suggested-course', - }, - { - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - courseName: 'Suggested course 2 with a really really really long name for some reason', - courseUrl: 'www.edx/suggested-course', - }, - { - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - courseName: 'Suggested course 3', - courseUrl: 'www.edx/suggested-course', - }, - { - bannerImgSrc: 'https://prod-discovery.edx-cdn.org/media/programs/banner_images/9a310b98-8f27-439e-be85-12d6460245c9-f2efca129273.small.jpg', - courseName: 'Suggested course 4', - courseUrl: 'www.edx/suggested-course', - }, - ], - socialShareSettings: { - facebook: { - isEnabled: true, - socialBrand: 'edx.org', - utmParams: 'utm_campaign=social-sharing-db&utm_medium=social&utm_source=facebook', - }, - twitter: { - isEnabled: true, - socialBrand: 'edx.org', - utmParams: 'utm_campaign=social-sharing-db&utm_medium=social&utm_source=twitter', - }, - }, -}; - -export const genCourseRunData = (data = {}) => ({ - isStarted: false, - isArchived: false, - startDate: data.isStarted ? farPastDate : futureDate, - endDate: farFutureDate, - minPassingGrade: '0.70', - homeUrl: 'edx.com/courses/my-course-url/home', - marketingUrl: 'edx.com/courses/my-course-url/marketing', - progressUrl: 'edx.com/courses/my-course-url/progress', - unenrollUrl: 'edx.com/courses/my-course-url/unenroll', - resumeUrl: 'edx.com/courses/my-course-url/resume', - ...data, -}); - -export const creditData = { - providerStatusUrl: 'test-provider-status-url', - providerName: 'Credit Provider Name', - providerId: 'credit-provider-id', - error: false, - purchased: false, - requestStatus: null, -}; - -export const genEnrollmentData = (data = {}) => ({ - coursewareAccess: { - isTooEarly: false, - hasUnmetPrerequisites: false, - isStaff: false, - }, - accessExpirationDate: ((data.isEnrolled === false) ? null : futureDate), - canUpgrade: (data.isVerified ? null : true), - hasStarted: false, - isAudit: !data.isVerified && (data.isEnrolled !== false), - isAuditAccessExpired: data.isVerified ? null : false, - isEmailEnabled: false, - hasOptedOutOfEmail: false, - isEnrolled: true, - isVerified: false, - ...data, -}); - -export const genCertificateData = (data = {}) => ({ - availableDate: null, - isRestricted: false, - isEarned: false, - isDownloadable: false, - certPreviewUrl: 'edx.com/courses/my-course-url/cert-preview', - ...data, -}); - -export const availableSessions = [ - { - startDate: '2000-01-02T00:00:00Z', - endDate: '2020-01-02T00:00:00Z', - courseId: genCourseId(1000), - }, - { - startDate: '2000-02-03T00:00:00Z', - endDate: '2020-02-03T00:00:00Z', - courseId: genCourseId(1001), - }, - { - startDate: '2000-03-04T00:00:00Z', - endDate: '2020-03-04T00:00:00Z', - courseId: genCourseId(1002), - }, - { - startDate: '2000-04-05T00:00:00Z', - endDate: '2020-04-05T00:00:00Z', - courseId: genCourseId(1003), - }, - { - startDate: '2000-05-06T00:00:00Z', - endDate: '2020-05-06T00:00:00Z', - courseId: genCourseId(1004), - }, -]; - -const auditCourses = [ - // audit, course run not started - { - courseName: 'Audit Course, Course run not started', - }, - // audit, course run not started, too early to view - { - courseName: 'Audit Course, Course run not started, Too early to view', - enrollment: { - coursewareAccess: { - isTooEarly: true, - hasUnmetPrerequisites: false, - isStaff: false, - }, - }, - }, - // audit, course run not started, too early to view and unmet prereqs - { - courseName: 'Audit Course, Course run not started, Too early to view, Has unmet prereqs.', - enrollment: { - coursewareAccess: { - isTooEarly: true, - hasUnmetPrerequisites: true, - isStaff: false, - }, - }, - }, - // audit, course run started - { - courseName: 'Audit Course, Course run not started', - courseRun: { isStarted: true }, - }, - // audit, course run started, unmet prereqs - { - courseName: 'Audit Course, Course run not started, Has unmet prereqs', - enrollment: { - coursewareAccess: { - isTooEarly: true, - hasUnmetPrerequisites: true, - isStaff: false, - }, - }, - courseRun: { isStarted: true }, - }, - // audit, course run started, access expired, learner not started - { - courseName: 'Audit Course, Course run started, Audit ccess expired, Learner not started', - courseRun: { isStarted: true }, - enrollment: { - accessExpirationDate: pastDate, - isAuditAccessExpired: true, - }, - }, - // audit, course run started, access expired, cannot upgrade, learner not started - { - courseName: 'Audit course, Course run not started, Audit access expired, Cannot upgrade, Learner not started', - courseRun: { isStarted: true }, - enrollment: { - accessExpirationDate: pastDate, - canUpgrade: false, - isAuditAccessExpired: true, - }, - }, - // audit, course run ended, access expired, cannot upgrade, learner not started - { - courseName: 'Audit Course, Course run ended, Audit access expired, Cannot upgrade, Learner not started', - courseRun: { - endDate: pastDate, - isStarted: true, - }, - enrollment: { - accessExpirationDate: pastDate, - isAuditAccessExpired: true, - }, - }, - // audit, course run archived, access expired, cannot upgrade, learner not started - { - courseName: 'Audit Course, Course run archived, Audit access expired, Cannot upgrade, Learner not started', - courseRun: { - endDate: pastDate, - isArchived: true, - isStarted: true, - }, - enrollment: { - accessExpirationDate: pastDate, - isAuditAccessExpired: true, - }, - }, - // audit, course run and learner started, passing - { - courseName: 'Audit Course, Course run and learner started, Passing', - courseRun: { isStarted: true }, - enrollment: { hasStarted: true }, - }, - // audit, course run and learner started, access expired - { - courseName: 'Audit Course, Course run and learner started, Audit access expired', - courseRun: { isStarted: true }, - enrollment: { - accessExpirationDate: pastDate, - isAuditAccessExpired: true, - hasStarted: true, - }, - }, - // audit, course run and learner started, access expired, cannot upgrade - { - courseName: 'Audit Course, Course run and learner started, Audit access expired, Cannot upgrade', - courseRun: { isStarted: true }, - enrollment: { - accessExpirationDate: pastDate, - canUpgrade: false, - isAuditAccessExpired: true, - hasStarted: true, - }, - }, - // audit, course run ended, learner started, expired, cannot upgraded, not passing - { - courseName: 'Audit Course, Course run ended, Learner started, Access expired, Cannot upgrade, Not passing', - courseRun: { - isStarted: true, - endDate: pastDate, - }, - enrollment: { - accessExpirationDate: pastDate, - canUpgrade: false, - isAuditAccessExpired: true, - hasStarted: true, - }, - grade: { isPassing: false }, - }, - // audit, course run archived, learner started, expired, cannot upgrade, not passing - { - courseName: 'Audit Course, Course run archived, Learner started, Access expired, Cannot upgrade, Not passing', - courseRun: { - isStarted: true, - isArchived: true, - endDate: pastDate, - }, - enrollment: { - accessExpirationDate: pastDate, - canUpgrade: false, - isAuditAccessExpired: true, - hasStarted: true, - }, - grade: { isPassing: false }, - }, -]; -const verifiedCourses = [ - // verified, course not started, learner not started - { - courseName: 'Verified Course, Course and learner not started', - enrollment: { isVerified: true }, - }, - // verified, course started, learner not started - { - courseName: 'Verified Course, Course started, Learner not started', - courseRun: { isStarted: true }, - enrollment: { isVerified: true }, - }, - // verified, course started, learner started, passing - { - courseName: 'Verified Course, Course and learner started, Passing', - courseRun: { isStarted: true }, - enrollment: { hasStarted: true, isVerified: true }, - }, - // verified, course started, learner started, not passing - { - courseName: 'Verified Course, Course and learner started, not passing', - courseRun: { isStarted: true }, - gradeData: { isPassing: false }, - enrollment: { hasStarted: true, isVerified: true }, - }, - // verified, learner finished, not passing, cert not earned - { - courseName: 'Verified Course, Learner finished, cert not earned', - enrollment: { - hasStarted: true, - isVerified: true, - }, - courseRun: { isStarted: true, isArchived: true }, - gradeData: { isPassing: false }, - certificate: { - isEarned: false, - }, - }, - // verified, learner finished, passing, cert earned but not available - { - courseName: 'Verified Course, Learner finished, Cert earned but not available', - enrollment: { - hasStarted: true, - isVerified: true, - }, - courseRun: { isStarted: true, isArchived: true }, - certificate: { - isEarned: true, - availableDate: futureDate, - }, - }, - // verified, learner finished, passing, restricted - { - courseName: 'Verified Course, Learner finished, Passing, Certificate restricted', - enrollment: { - hasStarted: true, - isVerified: true, - }, - courseRun: { isStarted: true, isArchived: true }, - certificate: { isRestricted: true }, - }, - // verified, learner finished, cert earned, downloadable (web + link) - { - courseName: 'Verified Course, Learner finished, Passing, Certificate downloadable and viewable', - enrollment: { - hasStarted: true, - isVerified: true, - }, - courseRun: { isStarted: true, isArchived: true }, - certificate: { - isEarned: true, - isDownloadable: true, - availableDate: pastDate, - certPreviewUrl: bannerImgSrc, - }, - }, - // verified, course ended, learner finished, cert earned, downloadable (link only), - { - courseName: 'Verified Course, Course ended, Learner finished, Passing, Certificate downloadable', - enrollment: { - hasStarted: true, - isVerified: true, - }, - courseRun: { - isStarted: true, - isArchived: true, - endDate: pastDate, - }, - certificate: { - isEarned: true, - isDownloadable: true, - availableDate: pastDate, - }, - }, - // verified, course ended, learner finished, cert earned, downloadable (web + link) - { - courseName: 'Verified Course, Course ended, Learner finished, Passing, Certificate downloadable and viewable', - enrollment: { - hasStarted: true, - isVerified: true, - }, - courseRun: { - isStarted: true, - isArchived: true, - endDate: pastDate, - }, - certificate: { - isEarned: true, - isDownloadable: true, - availableDate: pastDate, - certPreviewUrl: bannerImgSrc, - }, - }, -]; -const fulfilledEntitlementCourses = [ - // Entitlement - not started - { - courseName: 'Entitlement Course, not started', - enrollment: { - isVerified: true, - coursewareAccess: { - isTooEarly: true, - hasUnmetPrerequisites: false, - isStaff: false, - }, - }, - courseRun: { isStarted: false }, - entitlement: { - uuid: genEntitlementUUID(0), - availableSessions, - changeDeadline: futureDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: true, - isRefundable: true, - }, - }, - // Entitlement - Course run started, learner not started, unmet prereqs - { - courseName: 'Entitlement Course, Course run started, Learner not started, Has unmet prereqs', - enrollment: { - isVerified: true, - coursewareAccess: { - }, - }, - courseRun: { isStarted: true }, - entitlement: { - uuid: genEntitlementUUID(1), - availableSessions, - changeDeadline: futureDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: true, - isRefundable: true, - }, - }, - // Entitlement - Course run started, learner started, not passing - { - courseName: 'Entitlement Course, Course run started, Learner started, Not passing', - enrollment: { - isVerified: true, - hasStarted: true, - }, - courseRun: { isStarted: true }, - entitlement: { - uuid: genEntitlementUUID(2), - availableSessions, - changeDeadline: futureDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: true, - isRefundable: true, - }, - gradeData: { isPassing: false }, - }, - // Entitlement - Course run started, learner started, passing, cannot change - { - courseName: 'Entitlement Course, Course run and learner started, Passing, Cannot change sessions', - enrollment: { - isVerified: true, - hasStarted: true, - }, - courseRun: { isStarted: true }, - entitlement: { - uuid: genEntitlementUUID(3), - availableSessions, - changeDeadline: pastDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: true, - isRefundable: true, - }, - }, - // Entitlement - Learner finished, but did not pass - { - courseName: 'Entitlement Course, Learner finished but did not pass', - enrollment: { - isVerified: true, - }, - courseRun: { isStarted: true, isArchived: false }, - entitlement: { - uuid: genEntitlementUUID(4), - availableSessions: null, - changeDeadline: pastDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: true, - isRefundable: false, - }, - gradeData: { isPassing: false }, - }, - // Entitlement - Learner finished, and passed. cannot refund. previewable cert. - { - courseName: 'Entitlement course, Learner finished and passed, Cannot refund, Previewable Cert', - enrollment: { - isVerified: true, - }, - courseRun: { isStarted: true, isArchived: false }, - entitlement: { - uuid: genEntitlementUUID(5), - availableSessions: null, - changeDeadline: pastDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: true, - isRefundable: false, - }, - certificate: { - isEarned: true, - isDownloadable: true, - availableDate: pastDate, - certPreviewUrl: bannerImgSrc, - }, - }, - // Entitlement - Learner finished and failed. cannot refund. course ended. - { - courseName: 'Entitlement Course, Learner finished and failed, Cannot refund, Course ended', - enrollment: { - isVerified: true, - }, - courseRun: { - isStarted: true, - isArchived: false, - endDate: pastDate, - }, - entitlement: { - uuid: genEntitlementUUID(6), - availableSessions: null, - enrollmentUrl: '/entitlement-enrollment', - isFulfilled: true, - isRefundable: false, - changeDeadline: pastDate, - isExpired: false, - }, - gradeData: { isPassing: false }, - }, - // Entitlement - Learner finished and passed. cannot refund. cert downloadable - { - courseName: 'Entitlement Course, Learner finished and passed, Cannot refund, Cert downloadable', - enrollment: { - isVerified: true, - }, - courseRun: { - isArchived: true, - isStarted: true, - endDate: pastDate, - }, - entitlement: { - uuid: genEntitlementUUID(7), - availableSessions: null, - changeDeadline: pastDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isRefundable: false, - isFulfilled: true, - }, - certificate: { - isEarned: true, - isDownloadable: true, - availableDate: pastDate, - certPreviewUrl: bannerImgSrc, - }, - }, -]; -const creditCourses = [ - { - courseName: 'Credit - Eligible for credit from unknown provider', - credit: { - ...creditData, - providerName: null, - providerId: null, - }, - enrollment: { isEnrolled: true }, - }, - { - courseName: 'Credit - Eligible for credit from known provider', - credit: creditData, - enrollment: { isEnrolled: true }, - }, - { - courseName: 'Credit - Purchased but must request', - credit: { ...creditData, purchased: true }, - enrollment: { isEnrolled: true }, - }, - { - courseName: 'Credit - Credit Request Pending', - credit: { - ...creditData, - purchased: true, - requestStatus: creditVals.requestStatuses.pending, - }, - enrollment: { isEnrolled: true }, - }, - { - courseName: 'Credit - Credit Request Approved', - credit: { - ...creditData, - purchased: true, - requestStatus: creditVals.requestStatuses.approved, - }, - enrollment: { isEnrolled: true }, - }, - { - courseName: 'Credit - Credit Request Rejected, Error thrown', - credit: { - ...creditData, - purchased: true, - requestStatus: creditVals.requestStatuses.rejected, - error: true, - }, - enrollment: { isEnrolled: true }, - }, -]; - -export const courseRuns = [ - ...auditCourses, - ...verifiedCourses, - ...fulfilledEntitlementCourses, - ...creditCourses, -]; - -// unfulfilled entitlement select session -// unfulfilled entitlement select session with deadline -// unfulfilled entitlement select session pass deadline with available session {banner different from 4th} -// unfulfilled entitlement select session pass deadline without available session -export const entitlementCourses = [ - { - courseName: 'Unfulfilled Entitlement select session', - entitlement: { - uuid: genEntitlementUUID(10), - availableSessions, - changeDeadline: futureDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: false, - isRefundable: true, - }, - }, { - courseName: 'Unfulfilled Entitlement select session with upcoming deadline', - entitlement: { - uuid: genEntitlementUUID(11), - availableSessions, - changeDeadline: soonDateStr, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: false, - isRefundable: true, - }, - }, { - courseName: 'Unfulfilled Entitlement select session past deadline, With available session', - entitlement: { - uuid: genEntitlementUUID(12), - availableSessions, - changeDeadline: pastDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: false, - isFulfilled: false, - isRefundable: true, - }, - }, { - courseName: 'Unfulfilled Entitlement select session past deadline, With available no session', - entitlement: { - uuid: genEntitlementUUID(13), - availableSessions: [], - changeDeadline: pastDate, - enrollmentUrl: '/entitlement-enrollment', - isExpired: true, - isFulfilled: false, - isRefundable: true, - }, - }, -]; - -const providerOptions = [ - providers.edx, - providers.mit, - null, -]; - -const emailOptions = [ - { isEmailEnabled: false, hasOptedOutOfEmail: false }, - { isEmailEnabled: true, hasOptedOutOfEmail: false }, - { isEmailEnabled: true, hasOptedOutOfEmail: true }, -]; - -const programsOptions = [ - { relatedPrograms }, - { relatedPrograms: [relatedPrograms[0]] }, - { relatedPrograms: [] }, -]; - -const getOption = (options, index) => options[index % options.length]; - -export const compileCourseRunData = ({ courseName, ...data }, index) => { - const courseId = genCourseId(index); - const courseNumber = genCourseNumber(index); - const socialShareUrl = genCourseShareUrl(index); - const lastEnrolledDate = new Date(); - lastEnrolledDate.setDate(lastEnrolledDate.getDate() - index - 1); - const lastEnrolled = lastEnrolledDate.toISOString(); - const out = { - gradeData: { isPassing: true }, - entitlement: null, - credit: {}, - ...data, - certificate: genCertificateData(data.certificate), - enrollment: genEnrollmentData({ - lastEnrolled, - ...getOption(emailOptions, index), - ...data.enrollment, - }), - courseRun: genCourseRunData({ - ...data.courseRun, - courseId, - }), - course: { - courseName, - bannerImgSrc, - courseNumber, - socialShareUrl, - }, - courseProvider: getOption(providerOptions, index), - programs: getOption(programsOptions, index), - }; - return out; -}; - -export const compileEntitlementData = ({ courseName, ...data }, index) => { - const courseNumber = genCourseNumber(100 + index); - const socialShareUrl = genCourseShareUrl(100 + index); - return { - enrollment: genEnrollmentData({ - isEnrolled: false, - lastEnrolled: null, - accessExpirationDate: null, - canUpgrade: false, - hasStarted: false, - isAudit: false, - isAuditAccessExpired: false, - isEmailEnabled: false, - isVerified: false, - }), - gradeData: null, - certificate: null, - courseRun: null, - ...data, - course: { - courseName, - courseNumber, - bannerImgSrc, - socialShareUrl, - }, - courseProvider: getOption(providerOptions, index), - programs: getOption(programsOptions, index), - }; -}; - -// Entitlement Course - refundable -// Entitlement Course - cannot view yet -// Entitlement Course - can view and change -// Entitlement Course - expired -export const courseRunData = courseRuns.map(compileCourseRunData); -export const entitlementData = entitlementCourses.map(compileEntitlementData); - -export default { - compileEntitlementData, - compileCourseRunData, - courseRunData, - entitlementData, - globalData, -}; diff --git a/src/data/services/lms/fakeData/testUtils.js b/src/data/services/lms/fakeData/testUtils.js deleted file mode 100644 index 3b928b060..000000000 --- a/src/data/services/lms/fakeData/testUtils.js +++ /dev/null @@ -1,40 +0,0 @@ -// import { StrictDict } from 'utils'; -import { - ErrorStatuses, - // RequestKeys, -} from '../../../../data/constants/requests'; -import { actions } from '../../../../data/redux'; - -export const errorData = (status, data = '') => ({ - response: { - status, - data, - }, -}); - -export const networkErrorData = errorData(ErrorStatuses.badRequest); - -export const genTestUtils = ({ - dispatch, -}) => { - /* - const mockStart = (requestKey) => () => { - dispatch(actions.requests.startRequest(requestKey)); - }; - */ - - const mockError = (requestKey, status, data) => () => { - dispatch(actions.requests.failRequest({ - requestKey, - error: errorData(status, data), - })); - }; - const mockNetworkError = (requestKey) => ( - mockError(requestKey, ErrorStatuses.badRequest) - ); - return { - mockNetworkError, - }; -}; - -export default genTestUtils; diff --git a/src/data/services/lms/index.js b/src/data/services/lms/index.js index c79aa5f06..fc90b81ed 100644 --- a/src/data/services/lms/index.js +++ b/src/data/services/lms/index.js @@ -1,4 +1,4 @@ -import { StrictDict } from 'utils'; +import { StrictDict } from '@src/utils'; import api from './api'; import urls from './urls'; diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js index 457c76a33..055d3e759 100644 --- a/src/data/services/lms/urls.js +++ b/src/data/services/lms/urls.js @@ -3,8 +3,6 @@ import { StrictDict } from '../../../utils'; import { getAppConfig, getSiteConfig } from '@openedx/frontend-base'; -export const getEcommerceUrl = () => getAppConfig(appId).ECOMMERCE_BASE_URL; - const getBaseUrl = () => getSiteConfig().lmsBaseUrl; export const getApiUrl = () => (`${getSiteConfig().lmsBaseUrl}/api`); @@ -25,7 +23,12 @@ export const learningMfeUrl = (url) => updateUrl(getAppConfig(appId).LEARNING_BA // static view url const programsUrl = () => baseAppUrl('/dashboard/programs'); -export const creditPurchaseUrl = (courseId) => `${getEcommerceUrl()}/credit/checkout/${courseId}/`; +export const creditPurchaseUrl = (courseId) => { + const config = getAppConfig(appId); + return config.CREDIT_PURCHASE_URL + ? `${config.CREDIT_PURCHASE_URL}/${courseId}/` + : `${config.ECOMMERCE_BASE_URL}/credit/checkout/${courseId}/`; +}; export const creditRequestUrl = (providerId) => `${getApiUrl()}/credit/v1/providers/${providerId}/request/`; export default StrictDict({ diff --git a/src/data/services/lms/urls.test.js b/src/data/services/lms/urls.test.js index a0684582a..60aa00b53 100644 --- a/src/data/services/lms/urls.test.js +++ b/src/data/services/lms/urls.test.js @@ -39,6 +39,13 @@ describe('urls', () => { const url = urls.creditPurchaseUrl(courseId); expect(url).toEqual(expect.stringContaining(courseId)); }); + it('returns CREDIT_PURCHASE_URL if set, with courseId', () => { + const courseId = 'test-course-id'; + const config = getAppConfig(appId); + config.CREDIT_PURCHASE_URL = 'http://credit-purchase.example.com'; + const url = urls.creditPurchaseUrl(courseId); + expect(url).toBe(`http://credit-purchase.example.com/${courseId}/`); + }); }); describe('creditRequestUrl', () => { it('builds from api url and loads providerId', () => { diff --git a/src/data/store.js b/src/data/store.js deleted file mode 100755 index 25fed691c..000000000 --- a/src/data/store.js +++ /dev/null @@ -1,25 +0,0 @@ -import * as redux from 'redux'; -import thunkMiddleware from 'redux-thunk'; -import { - composeWithDevToolsLogOnlyInProduction, -} from '@redux-devtools/extension'; -import { createLogger } from 'redux-logger'; - -import reducer from './redux'; - -export const createStore = () => { - const loggerMiddleware = createLogger(); - - const middleware = [thunkMiddleware, loggerMiddleware]; - - const store = redux.createStore( - reducer, - composeWithDevToolsLogOnlyInProduction(redux.applyMiddleware(...middleware)), - ); - - return store; -}; - -const store = createStore(); - -export default store; diff --git a/src/data/store.test.js b/src/data/store.test.js deleted file mode 100644 index 07d5dbb06..000000000 --- a/src/data/store.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import { applyMiddleware } from 'redux'; -import thunkMiddleware from 'redux-thunk'; -import { - composeWithDevToolsLogOnlyInProduction, -} from '@redux-devtools/extension'; -import { createLogger } from 'redux-logger'; - -import rootReducer from './redux'; - -import exportedStore, { createStore } from './store'; - -jest.mock('./redux', () => ({ - __esModule: true, - default: 'REDUCER', - actions: 'ACTIONS', - selectors: 'SELECTORS', -})); - -jest.mock('redux-logger', () => ({ - createLogger: () => 'logger', -})); -jest.mock('redux-thunk', () => 'thunkMiddleware'); -jest.mock('redux', () => ({ - applyMiddleware: (...middleware) => ({ applied: middleware }), - createStore: (reducer, middleware) => ({ reducer, middleware }), -})); -jest.mock('@redux-devtools/extension', () => ({ - composeWithDevToolsLogOnlyInProduction: (middleware) => ({ withDevTools: middleware }), -})); - -describe('store aggregator module', () => { - describe('exported store', () => { - it('is generated by createStore', () => { - expect(exportedStore).toEqual(createStore()); - }); - it('creates store with connected reducers', () => { - expect(createStore().reducer).toEqual(rootReducer); - }); - describe('middleware', () => { - it('exports thunk and logger middleware, composed and applied with dev tools', () => { - expect(createStore().middleware).toEqual( - composeWithDevToolsLogOnlyInProduction(applyMiddleware(thunkMiddleware, createLogger())), - ); - }); - }); - }); -}); diff --git a/src/data/utils.js b/src/data/utils.js deleted file mode 100644 index 40bcfc4b1..000000000 --- a/src/data/utils.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Simple selector factory. - * Takes a list of string keys, and returns a simple slector for each. - * - * @function - * @param {Object|string[]} keys - If passed as object, Object.keys(keys) is used. - * @return {Object} - object of `{[key]: ({key}) => key}` - */ -const simpleSelectorFactory = (transformer, keys) => { - const selKeys = Array.isArray(keys) ? keys : Object.keys(keys); - return selKeys.reduce( - (obj, key) => ({ - ...obj, [key]: (state) => transformer(state)[key], - }), - { root: (state) => transformer(state) }, - ); -}; - -export default simpleSelectorFactory; diff --git a/src/data/utils.test.js b/src/data/utils.test.js deleted file mode 100644 index fd870d259..000000000 --- a/src/data/utils.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import simpleSelectorFactory from './utils'; - -describe('Redux utilities - creators', () => { - describe('simpleSelectors', () => { - const data = { a: 1, b: 2, c: 3 }; - const state = { - testGroup: data, - other: 'stuff', - }; - const transformer = ({ testGroup }) => testGroup; - - test('given a list of strings, returns a dict w/ a simple selector per string', () => { - const keys = ['a', 'b']; - const selectors = simpleSelectorFactory(transformer, keys); - expect(Object.keys(selectors)).toEqual(['root', ...keys]); - expect(selectors.root(state)).toEqual(data); - expect(selectors.a(state)).toEqual(data.a); - expect(selectors.b(state)).toEqual(data.b); - }); - test('given an object for keys, returns a dict w/ simple selector per key', () => { - const selectors = simpleSelectorFactory(transformer, data); - expect(Object.keys(selectors)).toEqual(['root', ...Object.keys(data)]); - expect(selectors.root(state)).toEqual(data); - expect(selectors.a(state)).toEqual(data.a); - expect(selectors.b(state)).toEqual(data.b); - expect(selectors.c(state)).toEqual(data.c); - }); - }); -}); diff --git a/src/hooks/api.js b/src/hooks/api.js deleted file mode 100644 index 2c30b1189..000000000 --- a/src/hooks/api.js +++ /dev/null @@ -1,123 +0,0 @@ -import { useContext } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; - -import { SiteContext } from '@openedx/frontend-base'; - -import GlobalDataContext from '../data/contexts/GlobalDataContext'; -import MasqueradeUserContext from '../data/contexts/MasqueradeUserContext'; - -import { RequestKeys } from '../data/constants/requests'; -import { post } from '../data/services/lms/utils'; -import api from '../data/services/lms/api'; - -import * as reduxHooks from '../data/redux/hooks'; -import * as module from './api'; - -const { useMakeNetworkRequest } = reduxHooks; - -export const useNetworkRequest = (action, args) => { - const makeNetworkRequest = useMakeNetworkRequest(); - return (...actionsArgs) => makeNetworkRequest({ - promise: action(...actionsArgs), - ...args, - }); -}; - -/** - * initialize the app, loading ora and course metadata from the api, and loading the initial - * submission list data. - */ -export const useInitializeApp = () => { - const { masqueradeUser, setMasqueradeIsSuccess, setMasqueradeIsPending, setMasqueradeIsError, setMasqueradeError } = useContext(MasqueradeUserContext); - const { setEmailConfirmation, setPlatformSettings } = useContext(GlobalDataContext); - const loadData = reduxHooks.useLoadData(); - - const query = useQuery({ - queryKey: [RequestKeys.initialize, masqueradeUser], - queryFn: async () => api.initializeList({ user: masqueradeUser }), - retry: false, - }); - - // Masquerade handles errors independenty. - if (masqueradeUser) { - setMasqueradeIsPending(query.isPending); - setMasqueradeIsError(query.isError); - setMasqueradeError(query.error); - } - - if (!query.isPending && !query.isError && query.data?.data) { - if (masqueradeUser) { - setMasqueradeIsSuccess(true); - } - // Load data into React contexts. - const { emailConfirmation, platformSettings } = query.data.data; - setEmailConfirmation(emailConfirmation); - setPlatformSettings(platformSettings); - - // Load data into Redux. - loadData(query.data.data); - } - - return query; -}; - -export const useNewEntitlementEnrollment = (cardId) => { - const { uuid } = reduxHooks.useCardEntitlementData(cardId); - const queryClient = useQueryClient(); - const onSuccess = () => queryClient.invalidateQueries({ queryKey: [RequestKeys.initialize] }); - - return module.useNetworkRequest( - (selection) => api.updateEntitlementEnrollment({ uuid, courseId: selection }), - { onSuccess, requestKey: RequestKeys.newEntitlementEnrollment }, - ); -}; - -export const useSwitchEntitlementEnrollment = (cardId) => { - const { uuid } = reduxHooks.useCardEntitlementData(cardId); - const queryClient = useQueryClient(); - const onSuccess = () => queryClient.invalidateQueries({ queryKey: [RequestKeys.initialize] }); - const action = (selection) => api.updateEntitlementEnrollment({ uuid, courseId: selection }); - return module.useNetworkRequest( - action, - { onSuccess, requestKey: RequestKeys.switchEntitlementSession }, - ); -}; - -export const useLeaveEntitlementSession = (cardId) => { - const { uuid, isRefundable } = reduxHooks.useCardEntitlementData(cardId); - const queryClient = useQueryClient(); - const onSuccess = () => queryClient.invalidateQueries({ queryKey: [RequestKeys.initialize] }); - return module.useNetworkRequest( - () => api.deleteEntitlementEnrollment({ uuid, isRefundable }), - { onSuccess, requestKey: RequestKeys.leaveEntitlementSession }, - ); -}; - -export const useUnenrollFromCourse = (cardId) => { - const { courseId } = reduxHooks.useCardCourseRunData(cardId); - return module.useNetworkRequest( - () => api.unenrollFromCourse({ courseId }), - { requestKey: RequestKeys.unenrollFromCourse }, - ); -}; - -export const useUpdateEmailSettings = (cardId) => { - const { courseId } = reduxHooks.useCardCourseRunData(cardId); - return module.useNetworkRequest( - (enable) => api.updateEmailSettings({ courseId, enable }), - { requestKey: RequestKeys.updateEmailSettings }, - ); -}; - -export const useSendConfirmEmail = () => { - const { emailConfirmation } = useContext(GlobalDataContext); - const { sendEmailUrl } = emailConfirmation; - return () => post(sendEmailUrl); -}; - -export const useCreateCreditRequest = (cardId) => { - const { providerId } = reduxHooks.useCardCreditData(cardId); - const { authenticatedUser: { username } } = useContext(SiteContext); - const { courseId } = reduxHooks.useCardCourseRunData(cardId); - return () => api.createCreditRequest({ providerId, courseId, username }); -}; diff --git a/src/hooks/api.test.js b/src/hooks/api.test.js deleted file mode 100644 index 59edf9563..000000000 --- a/src/hooks/api.test.js +++ /dev/null @@ -1,268 +0,0 @@ -import React from 'react'; -import { SiteContext } from '@openedx/frontend-base'; -import * as ReactQuery from '@tanstack/react-query'; -import keyStore from '@src/utils/keyStore'; -import { RequestKeys } from '@src/data/constants/requests'; -import api from '@src/data/services/lms/api'; -import { post } from '@src/data/services/lms/utils'; -import * as reduxHooks from '@src/data/redux/hooks'; -import * as apiHooks from './api'; - -const reduxKeys = keyStore(reduxHooks); - -jest.mock('@src/data/services/lms/utils', () => ({ - post: jest.fn((...args) => ({ post: args })), -})); -jest.mock('@src/data/services/lms/api', () => ({ - initializeList: jest.fn(), - updateEntitlementEnrollment: jest.fn(), - unenrollFromCourse: jest.fn(), - deleteEntitlementEnrollment: jest.fn(), - updateEmailSettings: jest.fn(), - createCreditRequest: jest.fn(), -})); -jest.mock('@src/data/redux/hooks', () => ({ - useCardCourseRunData: jest.fn(), - useCardCreditData: jest.fn(), - useCardEntitlementData: jest.fn(), - useLoadData: jest.fn(), - useMakeNetworkRequest: jest.fn(), - useClearRequest: jest.fn(), - useEmailConfirmationData: jest.fn(), -})); -jest.mock('@tanstack/react-query', () => ({ - ...jest.requireActual('@tanstack/react-query'), - useQuery: jest.fn(), - useQueryClient: jest.fn(), -})); -jest.mock('@src/data/contexts/GlobalDataContext', () => ({ - default: { - Provider: ({ children }) => children, - Consumer: ({ children }) => children({ - emailConfirmation: { sendEmailUrl: 'test-send-email-url' }, - }), - displayName: 'GlobalDataContext', - }, -})); -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: jest.fn(() => { - // Return the mocked context value for GlobalDataContext - return { emailConfirmation: { sendEmailUrl: 'test-send-email-url' } }; - }), -})); - -const moduleKeys = keyStore(apiHooks); -const testString = 'TEST-string'; -const uuid = 'test-UUID'; -const cardId = 'test-card-id'; -const selection = 'test-selection'; -const courseId = 'test-COURSE-id'; -const isRefundable = 'test-is-refundable'; - -const loadData = jest.fn(); -reduxHooks.useLoadData.mockReturnValue(loadData); -const clearRequest = jest.fn(); -reduxHooks.useClearRequest.mockReturnValue(clearRequest); - -reduxHooks.useCardCourseRunData.mockReturnValue({ courseId }); -reduxHooks.useCardEntitlementData.mockReturnValue({ uuid, isRefundable }); - -let hook; -let out; - -const testInitCardHook = (hookKey) => { - test(`initializes ${hookKey} with cardId`, () => { - expect(reduxHooks[hookKey]).toHaveBeenCalledWith(cardId); - }); -}; - -describe('api hooks', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('useNetworkRequest', () => { - const makeNetworkRequest = jest.fn(args => ({ networkRequest: args })); - const action = jest.fn((...actionArgs) => ({ action: actionArgs })); - const args = { some: 'test', args: 'for you' }; - it('returns network request based on incoming action', () => { - reduxHooks.useMakeNetworkRequest.mockReturnValue(makeNetworkRequest); - hook = apiHooks.useNetworkRequest(action, args); - expect(hook()).toEqual(makeNetworkRequest({ promise: action(), ...args })); - }); - it('forwards action arguments', () => { - reduxHooks.useMakeNetworkRequest.mockReturnValue(makeNetworkRequest); - hook = apiHooks.useNetworkRequest(action, args); - const actionArgs = [1, 2, 3]; - expect(hook(...actionArgs)).toEqual( - makeNetworkRequest({ promise: action(...actionArgs), ...args }), - ); - }); - }); - - describe('network requests', () => { - const mockUseNetworkRequest = jest.fn((action, args) => ({ action, args })); - const testRequestKey = (requestKey) => { - test('requestKey', () => { - expect(hook.args.requestKey).toEqual(requestKey); - }); - }; - - beforeEach(() => { - jest.spyOn(apiHooks, moduleKeys.useNetworkRequest).mockImplementation(mockUseNetworkRequest); - }); - describe('useInitializeApp', () => { - beforeEach(() => { - // Mock useInitializeApp to return a mock React Query object - jest.spyOn(apiHooks, 'useInitializeApp').mockReturnValue({ - data: null, - isLoading: false, - isError: false, - error: null, - }); - hook = { args: { requestKey: RequestKeys.initialize, onSuccess: loadData } }; - }); - testRequestKey(RequestKeys.initialize); - it('initializes load data hook', () => { - apiHooks.useInitializeApp(); - // Since useInitializeApp uses React Query, it doesn't directly call useLoadData - // in the same way as the other hooks. This test would need to be restructured - // for proper React Query testing. - expect(reduxHooks.useLoadData).toHaveBeenCalledTimes(0); - }); - it('calls loadData with data on success', () => { - hook.args.onSuccess(testString); - expect(loadData).toHaveBeenCalledWith(testString); - }); - }); - - describe('entitlement enrollment hooks', () => { - let mockQueryClient; - let invalidateQueries; - beforeEach(() => { - invalidateQueries = jest.fn(); - mockQueryClient = { invalidateQueries }; - // Mock useQueryClient from React Query - ReactQuery.useQueryClient.mockReturnValue(mockQueryClient); - }); - - const testInitialization = () => { - it('initializes useQueryClient', () => { - expect(ReactQuery.useQueryClient).toHaveBeenCalled(); - }); - testInitCardHook(reduxKeys.useCardEntitlementData); - }; - - const testArgs = (requestKey) => { - testRequestKey(requestKey); - it('invalidates initialize query on success', () => { - hook.args.onSuccess(); - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: [RequestKeys.initialize] }); - }); - }; - - describe('useNewEntitlementEnrollment', () => { - beforeEach(() => { - hook = apiHooks.useNewEntitlementEnrollment(cardId); - }); - testInitialization(); - testArgs(RequestKeys.newEntitlementEnrollment); - it('calls updateEntitlementEnrollment api method', () => { - hook.action(selection); - expect(api.updateEntitlementEnrollment) - .toHaveBeenCalledWith({ uuid, courseId: selection }); - }); - }); - - describe('useSwitchEntitlementEnrollment', () => { - beforeEach(() => { - hook = apiHooks.useSwitchEntitlementEnrollment(cardId); - }); - testInitialization(); - testArgs(RequestKeys.switchEntitlementSession); - it('calls updateEntitlementEnrollment api method', () => { - hook.action(selection); - expect(api.updateEntitlementEnrollment) - .toHaveBeenCalledWith({ uuid, courseId: selection }); - }); - }); - - describe('useLeaveEntitlementSession', () => { - beforeEach(() => { - hook = apiHooks.useLeaveEntitlementSession(cardId); - }); - testInitialization(); - testArgs(RequestKeys.leaveEntitlementSession); - it('calls updateEntitlementEnrollment api method', () => { - hook.action(); - expect(api.deleteEntitlementEnrollment) - .toHaveBeenCalledWith({ uuid, isRefundable }); - }); - }); - }); - - describe('useUnenrollFromCourse', () => { - beforeEach(() => { - hook = apiHooks.useUnenrollFromCourse(cardId); - }); - testInitCardHook(reduxKeys.useCardCourseRunData); - testRequestKey(RequestKeys.unenrollFromCourse); - it('calls unenrollFromCourse api method with courseId', () => { - hook.action(); - expect(api.unenrollFromCourse).toHaveBeenCalledWith({ courseId }); - }); - }); - - describe('useUpdateEmailSettings', () => { - const enable = 'test-enable'; - beforeEach(() => { - hook = apiHooks.useUpdateEmailSettings(cardId); - }); - testInitCardHook(reduxKeys.useCardCourseRunData); - testRequestKey(RequestKeys.updateEmailSettings); - it('calls updateEmailSettings api method on call', () => { - hook.action(enable); - expect(api.updateEmailSettings).toHaveBeenCalledWith({ courseId, enable }); - }); - }); - - describe('useSendConfirmEmail', () => { - const sendEmailUrl = 'test-send-email-url'; - beforeEach(() => { - hook = apiHooks.useSendConfirmEmail(); - out = hook(); - }); - it('uses GlobalDataContext to get emailConfirmation', () => { - // The hook should use the mocked context which returns sendEmailUrl - expect(out).toEqual(post(sendEmailUrl)); - }); - it('posts to email url on call', () => { - expect(out).toEqual(post(sendEmailUrl)); - }); - }); - - describe('useCreateCreditRequest', () => { - const username = 'test-username'; - const providerId = 'test-provider-id'; - beforeEach(() => { - React.useContext.mockReturnValue({ authenticatedUser: { username } }); - reduxHooks.useCardCreditData.mockReturnValue({ providerId }); - hook = apiHooks.useCreateCreditRequest(cardId); - }); - testInitCardHook(reduxKeys.useCardCreditData); - testInitCardHook(reduxKeys.useCardCourseRunData); - it('initializes username from app context', () => { - expect(React.useContext).toHaveBeenCalledWith(SiteContext); - }); - it('calls createCreditRequest api method on call', () => { - out = hook(); - expect(api.createCreditRequest).toHaveBeenCalledWith({ - providerId, - courseId, - username, - }); - }); - }); - }); -}); diff --git a/src/hooks/index.js b/src/hooks/index.js index decbfe5c2..243b6120c 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1,7 +1,11 @@ -import * as redux from '../data/redux/hooks'; -import * as api from './api'; import * as utils from './utils'; +import useCourseData from './useCourseData'; +import useCourseTrackingEvent from './useCourseTrackingEvent'; +import useEntitlementInfo from './useEntitlementInfo'; +import useIsMasquerading from './useIsMasquerading'; -export const reduxHooks = redux; -export const apiHooks = api; export const utilHooks = utils; +export { useCourseData }; +export { useCourseTrackingEvent }; +export { useEntitlementInfo }; +export { useIsMasquerading }; diff --git a/src/hooks/useCourseData.test.tsx b/src/hooks/useCourseData.test.tsx new file mode 100644 index 000000000..fd3982cc4 --- /dev/null +++ b/src/hooks/useCourseData.test.tsx @@ -0,0 +1,150 @@ +import { renderHook } from '@testing-library/react'; +import { useInitializeLearnerHome } from '@src/data/hooks'; +import useCourseData from './useCourseData'; + +jest.mock('@src/data/hooks'); + +const mockUseInitializeLearnerHome = useInitializeLearnerHome as jest.MockedFunction; + +describe('useCourseData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockCoursesByCardId = { + 'course-1': { + cardId: 'course-1', + courseName: 'Introduction to React', + courseNumber: 'CS101', + enrollment: { isEnrolled: true }, + }, + 'course-2': { + cardId: 'course-2', + courseName: 'Advanced JavaScript', + courseNumber: 'CS201', + enrollment: { isEnrolled: true }, + }, + 'course-3': { + cardId: 'course-3', + courseName: 'Data Structures', + courseNumber: 'CS301', + enrollment: { isEnrolled: false }, + }, + }; + + describe('successful data retrieval', () => { + beforeEach(() => { + mockUseInitializeLearnerHome.mockReturnValue({ + data: { courses: [], coursesByCardId: mockCoursesByCardId }, + isLoading: false, + isError: false, + error: null, + } as any); + }); + + it('should return correct course data for existing cardId', () => { + const { result } = renderHook(() => useCourseData('course-1')); + expect(result.current).toEqual(mockCoursesByCardId['course-1']); + }); + + it('should return correct course data for different cardId', () => { + const { result } = renderHook(() => useCourseData('course-2')); + expect(result.current).toEqual(mockCoursesByCardId['course-2']); + }); + + it('should return undefined for non-existing cardId', () => { + const { result } = renderHook(() => useCourseData('non-existing-course')); + expect(result.current).toBeUndefined(); + }); + }); + + describe('no data scenarios', () => { + it('should handle undefined data gracefully', () => { + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + } as any); + + const { result } = renderHook(() => useCourseData('course-1')); + expect(result.current).toBeUndefined(); + }); + + it('should handle null data gracefully', () => { + mockUseInitializeLearnerHome.mockReturnValue({ + data: null, + isLoading: false, + isError: false, + error: null, + } as any); + + const { result } = renderHook(() => useCourseData('course-1')); + expect(result.current).toBeUndefined(); + }); + + it('should handle missing coursesByCardId property', () => { + mockUseInitializeLearnerHome.mockReturnValue({ + data: { courses: [] }, + isLoading: false, + isError: false, + error: null, + } as any); + + const { result } = renderHook(() => useCourseData('course-1')); + expect(result.current).toBeUndefined(); + }); + }); + + describe('loading and error states', () => { + it('should handle loading state', () => { + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + } as any); + + const { result } = renderHook(() => useCourseData('course-1')); + expect(result.current).toBeUndefined(); + }); + + it('should handle error state', () => { + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('API Error'), + } as any); + + const { result } = renderHook(() => useCourseData('course-1')); + expect(result.current).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + mockUseInitializeLearnerHome.mockReturnValue({ + data: { courses: [], coursesByCardId: mockCoursesByCardId }, + isLoading: false, + isError: false, + error: null, + } as any); + }); + + it('should handle empty string cardId', () => { + const { result } = renderHook(() => useCourseData('')); + expect(result.current).toBeUndefined(); + }); + + it('should handle null cardId', () => { + const { result } = renderHook(() => useCourseData(null as any)); + expect(result.current).toBeUndefined(); + }); + + it('should handle undefined cardId', () => { + const { result } = renderHook(() => useCourseData(undefined as any)); + expect(result.current).toBeUndefined(); + }); + }); +}); diff --git a/src/hooks/useCourseData.ts b/src/hooks/useCourseData.ts new file mode 100644 index 000000000..0679ae497 --- /dev/null +++ b/src/hooks/useCourseData.ts @@ -0,0 +1,8 @@ +import { useInitializeLearnerHome } from '@src/data/hooks'; + +const useCourseData = (cardId: string) => { + const { data } = useInitializeLearnerHome(); + return data?.coursesByCardId?.[cardId]; +}; + +export default useCourseData; diff --git a/src/hooks/useCourseTrackingEvent.test.tsx b/src/hooks/useCourseTrackingEvent.test.tsx new file mode 100644 index 000000000..f8adb15b7 --- /dev/null +++ b/src/hooks/useCourseTrackingEvent.test.tsx @@ -0,0 +1,389 @@ +import { renderHook } from '@testing-library/react'; +import useCourseData from './useCourseData'; +import useCourseTrackingEvent from './useCourseTrackingEvent'; + +jest.mock('./useCourseData'); + +const mockUseCourseData = useCourseData as jest.MockedFunction; + +describe('useCourseTrackingEvent', () => { + const mockTracker = jest.fn(); + const mockEvent = new Event('click'); + const testCardId = 'test-card-id'; + + beforeEach(() => { + jest.clearAllMocks(); + mockTracker.mockImplementation(() => jest.fn()); + }); + + describe('successful tracking scenarios', () => { + it('should call tracker with courseId when course data exists', () => { + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS101+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockUseCourseData).toHaveBeenCalledWith(testCardId); + expect(mockTracker).toHaveBeenCalledWith('course-v1:TestX+CS101+2024'); + }); + + it('should call tracker with courseId and additional arguments', () => { + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS201+2024', + }, + }; + + const additionalArgs = ['arg1', 'arg2', { option: 'value' }]; + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId, ...additionalArgs)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockTracker).toHaveBeenCalledWith( + 'course-v1:TestX+CS201+2024', + 'arg1', + 'arg2', + { option: 'value' }, + ); + }); + + it('should call the returned tracking function with the event', () => { + const mockTrackingFunction = jest.fn(); + mockTracker.mockReturnValue(mockTrackingFunction); + + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS301+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockTrackingFunction).toHaveBeenCalledWith(mockEvent); + }); + + it('should work with different event types', () => { + const mockTrackingFunction = jest.fn(); + mockTracker.mockReturnValue(mockTrackingFunction); + + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS401+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + + const clickEvent = new Event('click'); + const mouseEvent = new MouseEvent('mouseover'); + const customEvent = new CustomEvent('custom'); + + trackingHandler(clickEvent); + trackingHandler(mouseEvent); + trackingHandler(customEvent); + + expect(mockTrackingFunction).toHaveBeenCalledTimes(3); + expect(mockTrackingFunction).toHaveBeenNthCalledWith(1, clickEvent); + expect(mockTrackingFunction).toHaveBeenNthCalledWith(2, mouseEvent); + expect(mockTrackingFunction).toHaveBeenNthCalledWith(3, customEvent); + }); + }); + + describe('no courseId scenarios', () => { + it('should not call tracker when courseData is null', () => { + mockUseCourseData.mockReturnValue(null); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockUseCourseData).toHaveBeenCalledWith(testCardId); + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should not call tracker when courseData is undefined', () => { + mockUseCourseData.mockReturnValue(undefined); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockUseCourseData).toHaveBeenCalledWith(testCardId); + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should not call tracker when courseRun is missing', () => { + const mockCourseData = { + courseName: 'Test Course', + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should not call tracker when courseId is missing from courseRun', () => { + const mockCourseData = { + courseRun: { + courseName: 'Test Course', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should not call tracker when courseId is empty string', () => { + const mockCourseData = { + courseRun: { + courseId: '', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should not call tracker when courseId is null', () => { + const mockCourseData = { + courseRun: { + courseId: null, + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + }); + + describe('cardId variations', () => { + it('should work with different cardId values', () => { + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS501+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const cardIds = ['card-1', 'card-2', 'another-card-id']; + + cardIds.forEach(cardId => { + mockUseCourseData.mockClear(); + mockTracker.mockClear(); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, cardId)); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockUseCourseData).toHaveBeenCalledWith(cardId); + expect(mockTracker).toHaveBeenCalledWith('course-v1:TestX+CS501+2024'); + }); + }); + + it('should handle empty cardId', () => { + mockUseCourseData.mockReturnValue(null); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, '')); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(mockUseCourseData).toHaveBeenCalledWith(''); + expect(mockTracker).not.toHaveBeenCalled(); + }); + }); + + describe('hook behavior and memoization', () => { + it('should return a function', () => { + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS601+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + expect(typeof result.current).toBe('function'); + }); + + it('should create new function when dependencies change', () => { + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS701+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result, rerender } = renderHook( + ({ cardId, tracker }) => useCourseTrackingEvent(tracker, cardId), + { + initialProps: { + cardId: testCardId, + tracker: mockTracker, + }, + }, + ); + + const firstHandler = result.current; + + rerender({ cardId: 'different-card-id', tracker: mockTracker }); + + expect(result.current).not.toBe(firstHandler); + }); + }); + + describe('error handling', () => { + it('should handle when tracker throws an error', () => { + const errorTracker = jest.fn().mockImplementation(() => { + throw new Error('Tracker error'); + }); + + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS801+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(errorTracker, testCardId)); + + const trackingHandler = result.current; + + expect(() => trackingHandler(mockEvent)).toThrow('Tracker error'); + }); + + it('should handle when tracker returns null', () => { + const nullTracker = jest.fn().mockReturnValue(null); + + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS901+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(nullTracker, testCardId)); + + const trackingHandler = result.current; + + expect(() => trackingHandler(mockEvent)).toThrow(); + }); + + it('should handle when useCourseData throws an error', () => { + mockUseCourseData.mockImplementation(() => { + throw new Error('Course data error'); + }); + + expect(() => { + renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + }).toThrow('Course data error'); + }); + }); + + describe('integration scenarios', () => { + it('should work with realistic tracking scenario', () => { + const realTracker = jest.fn((courseId, action, category) => jest.fn(() => { + console.log(`Tracking ${action} for ${courseId} in ${category}`); + })); + + const mockCourseData = { + courseRun: { + courseId: 'course-v1:MITx+6.00.1x+2024', + courseName: 'Introduction to Computer Science', + }, + enrollment: { + isActive: true, + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(realTracker, 'mit-cs-card', 'click', 'course-card')); + + const trackingHandler = result.current; + trackingHandler(mockEvent); + + expect(realTracker).toHaveBeenCalledWith( + 'course-v1:MITx+6.00.1x+2024', + 'click', + 'course-card', + ); + }); + + it('should work with multiple tracking calls', () => { + const mockTrackingFunction = jest.fn(); + mockTracker.mockReturnValue(mockTrackingFunction); + + const mockCourseData = { + courseRun: { + courseId: 'course-v1:TestX+CS1001+2024', + }, + }; + + mockUseCourseData.mockReturnValue(mockCourseData); + + const { result } = renderHook(() => useCourseTrackingEvent(mockTracker, testCardId)); + + const trackingHandler = result.current; + + trackingHandler(mockEvent); + trackingHandler(mockEvent); + trackingHandler(mockEvent); + + expect(mockTracker).toHaveBeenCalledTimes(3); + expect(mockTrackingFunction).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/src/hooks/useCourseTrackingEvent.ts b/src/hooks/useCourseTrackingEvent.ts new file mode 100644 index 000000000..32363456e --- /dev/null +++ b/src/hooks/useCourseTrackingEvent.ts @@ -0,0 +1,14 @@ +import useCourseData from './useCourseData'; + +const useCourseTrackingEvent = (tracker: (...a: any[]) => any, cardId: string, ...args: any[]) => { + const courseData = useCourseData(cardId); + const courseId = (courseData as any)?.courseRun?.courseId; + + return (e: Event) => { + if (courseId) { + tracker(courseId, ...args)(e); + } + }; +}; + +export default useCourseTrackingEvent; diff --git a/src/hooks/useEntitlementInfo.test.tsx b/src/hooks/useEntitlementInfo.test.tsx new file mode 100644 index 000000000..02587ac51 --- /dev/null +++ b/src/hooks/useEntitlementInfo.test.tsx @@ -0,0 +1,534 @@ +import { renderHook } from '@testing-library/react'; +import useEntitlementInfo from './useEntitlementInfo'; + +describe('useEntitlementInfo', () => { + const today = new Date(); + const pastDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days ago + const futureDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days from now + const farFutureDate = new Date(today.getTime() + 200 * 24 * 60 * 60 * 1000); // 200 days from now + + const mockAvailableSessions = [ + { + sessionId: 'session-1', + courseName: 'Session 1', + startDate: '2024-03-01', + }, + { + sessionId: 'session-2', + courseName: 'Session 2', + startDate: '2024-06-01', + }, + ]; + + describe('non-entitlement scenarios', () => { + it('should return isEntitlement: false when entitlement is null', () => { + const courseData = { + entitlement: null, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current).toEqual({ + isEntitlement: false, + }); + }); + + it('should return isEntitlement: false when entitlement is undefined', () => { + const courseData = { + entitlement: undefined, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current).toEqual({ + isEntitlement: false, + }); + }); + + it('should return isEntitlement: false when entitlement is empty object', () => { + const courseData = { + entitlement: {}, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current).toEqual({ + isEntitlement: false, + }); + }); + + it('should return isEntitlement: false when entitlement.isEntitlement is false', () => { + const courseData = { + entitlement: { + isEntitlement: false, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current).toEqual({ + isEntitlement: false, + }); + }); + + it('should return isEntitlement: false when courseData is null', () => { + const { result } = renderHook(() => useEntitlementInfo(null)); + + expect(result.current).toEqual({ + isEntitlement: false, + }); + }); + + it('should return isEntitlement: false when courseData is undefined', () => { + const { result } = renderHook(() => useEntitlementInfo(undefined)); + + expect(result.current).toEqual({ + isEntitlement: false, + }); + }); + }); + + describe('valid entitlement scenarios', () => { + const baseEntitlementData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid-123', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + it('should return complete entitlement info for valid entitlement', () => { + const { result } = renderHook(() => useEntitlementInfo(baseEntitlementData)); + + expect(result.current).toMatchObject({ + isEntitlement: true, + availableSessions: mockAvailableSessions, + changeDeadline: expect.any(Date), + isExpired: false, + isFulfilled: false, + uuid: 'test-uuid-123', + hasSessions: true, + canChange: true, + showExpirationWarning: true, + }); + }); + + it('should correctly parse changeDeadline as Date object', () => { + const { result } = renderHook(() => useEntitlementInfo(baseEntitlementData)); + + expect(result.current.changeDeadline).toBeInstanceOf(Date); + expect(result.current.changeDeadline?.getTime()).toBe(futureDate.getTime()); + }); + + it('should return hasSessions: true when availableSessions has items', () => { + const { result } = renderHook(() => useEntitlementInfo(baseEntitlementData)); + + expect(result.current.hasSessions).toBe(true); + }); + + it('should return hasSessions: false when availableSessions is empty', () => { + const courseData = { + entitlement: { + ...baseEntitlementData.entitlement, + availableSessions: [], + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.hasSessions).toBe(false); + }); + + it('should return hasSessions: false when availableSessions is null', () => { + const courseData = { + entitlement: { + ...baseEntitlementData.entitlement, + availableSessions: null, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.hasSessions).toBe(false); + }); + + it('should return hasSessions: false when availableSessions is undefined', () => { + const courseData = { + entitlement: { + ...baseEntitlementData.entitlement, + availableSessions: undefined, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.hasSessions).toBe(false); + }); + }); + + describe('deadline and expiration logic', () => { + it('should return canChange: false when deadline has passed', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: pastDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.canChange).toBe(false); + }); + + it('should return canChange: true when deadline has not passed', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.canChange).toBe(true); + }); + + it('should return showExpirationWarning: false when already fulfilled', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: true, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.showExpirationWarning).toBe(false); + }); + + it('should return showExpirationWarning: false when deadline has passed', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: pastDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.showExpirationWarning).toBe(false); + }); + + it('should return showExpirationWarning: false when deadline is more than 6 months away', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: farFutureDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.showExpirationWarning).toBe(false); + }); + + it('should return showExpirationWarning: true when conditions are met', () => { + const withinSixMonths = new Date(today.getTime() + 90 * 24 * 60 * 60 * 1000); // 90 days from now + + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: withinSixMonths.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.showExpirationWarning).toBe(true); + }); + }); + + describe('entitlement properties passthrough', () => { + it('should pass through isExpired property', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + isExpired: true, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.isExpired).toBe(true); + }); + + it('should pass through isFulfilled property', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: true, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.isFulfilled).toBe(true); + }); + + it('should pass through uuid property', () => { + const testUuid = 'unique-entitlement-uuid-456'; + const courseData = { + entitlement: { + isEntitlement: true, + uuid: testUuid, + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.uuid).toBe(testUuid); + }); + + it('should pass through availableSessions property', () => { + const customSessions = [ + { sessionId: 'custom-1', courseName: 'Custom Session' }, + ]; + + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: customSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.availableSessions).toEqual(customSessions); + }); + }); + + describe('memoization behavior', () => { + it('should memoize result when courseData does not change', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result, rerender } = renderHook( + ({ data }) => useEntitlementInfo(data), + { initialProps: { data: courseData } }, + ); + + const firstResult = result.current; + + rerender({ data: courseData }); + + expect(result.current).toBe(firstResult); + }); + + it('should recalculate when courseData changes', () => { + const courseData1 = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid-1', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const courseData2 = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid-2', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: true, + availableSessions: mockAvailableSessions, + }, + }; + + const { result, rerender } = renderHook( + ({ data }) => useEntitlementInfo(data), + { initialProps: { data: courseData1 } }, + ); + + const firstResult = result.current; + + rerender({ data: courseData2 }); + + expect(result.current).not.toBe(firstResult); + expect(result.current.uuid).toBe('test-uuid-2'); + expect(result.current.isFulfilled).toBe(true); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle invalid date strings gracefully', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: 'invalid-date-string', + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.changeDeadline).toBeInstanceOf(Date); + }); + + it('should handle missing changeDeadline property', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.changeDeadline).toBeInstanceOf(Date); + }); + + it('should handle entitlement with missing optional properties', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.isEntitlement).toBe(true); + expect(result.current.uuid).toBe('test-uuid'); + expect(result.current.isExpired).toBeUndefined(); + expect(result.current.isFulfilled).toBeUndefined(); + expect(result.current.availableSessions).toBeUndefined(); + expect(result.current.hasSessions).toBe(false); + }); + + it('should handle courseData with extra properties', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: futureDate.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + course: { + courseName: 'Extra Course Info', + }, + enrollment: { + isActive: true, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.isEntitlement).toBe(true); + expect(result.current.uuid).toBe('test-uuid'); + }); + }); + + describe('boundary date calculations', () => { + it('should correctly calculate 6-month boundary for showExpirationWarning', () => { + const exactly180Days = new Date(); + exactly180Days.setDate(exactly180Days.getDate() + 180); + + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: exactly180Days.toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + + expect(result.current.showExpirationWarning).toBe(true); + }); + + it('should handle date exactly at deadline boundary', () => { + const courseData = { + entitlement: { + isEntitlement: true, + uuid: 'test-uuid', + changeDeadline: new Date().toISOString(), + isExpired: false, + isFulfilled: false, + availableSessions: mockAvailableSessions, + }, + }; + + const { result } = renderHook(() => useEntitlementInfo(courseData)); + expect(typeof result.current.canChange).toBe('boolean'); + expect(typeof result.current.showExpirationWarning).toBe('boolean'); + }); + }); +}); diff --git a/src/hooks/useEntitlementInfo.ts b/src/hooks/useEntitlementInfo.ts new file mode 100644 index 000000000..7dbe9d8a0 --- /dev/null +++ b/src/hooks/useEntitlementInfo.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; + +const useEntitlementInfo = (courseData) => useMemo(() => { + const { entitlement } = courseData || {}; + if (!entitlement || Object.keys(entitlement).length === 0 || entitlement.isEntitlement === false) { + return { isEntitlement: false }; + } + const today = new Date(); + const dateSixMonthsFromNow = new Date(); + dateSixMonthsFromNow.setDate(dateSixMonthsFromNow.getDate() + 180); + const deadline = new Date(entitlement.changeDeadline); + const deadlinePassed = deadline < today; + const showExpirationWarning = ( + !entitlement.isFulfilled + && !deadlinePassed + && deadline <= dateSixMonthsFromNow + ); + return { + isEntitlement: true, + + availableSessions: entitlement.availableSessions, + changeDeadline: deadline, + isExpired: entitlement.isExpired, + isFulfilled: entitlement.isFulfilled, + uuid: entitlement.uuid, + + hasSessions: entitlement.availableSessions?.length > 0, + canChange: !deadlinePassed, + showExpirationWarning, + }; +}, [courseData]); + +export default useEntitlementInfo; diff --git a/src/hooks/useIsMasquerading.test.tsx b/src/hooks/useIsMasquerading.test.tsx new file mode 100644 index 000000000..63799c9a2 --- /dev/null +++ b/src/hooks/useIsMasquerading.test.tsx @@ -0,0 +1,450 @@ +import { renderHook } from '@testing-library/react'; +import { useMasquerade } from '@src/data/context'; +import { useInitializeLearnerHome } from '@src/data/hooks'; +import { useIsMasquerading } from '@src/hooks'; + +jest.mock('@src/data/context'); +jest.mock('@src/data/hooks'); + +const mockUseMasquerade = useMasquerade as jest.MockedFunction; +const mockUseInitializeLearnerHome = useInitializeLearnerHome as jest.MockedFunction; + +describe('useIsMasquerading', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('returns true when masquerading successfully', () => { + it('should return true when masqueradeUser exists and no error', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'test-user', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(true); + }); + + it('should return true when masqueradeUser is a non-empty string and no error', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'admin@example.com', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(true); + }); + + it('should return true when masqueradeUser is valid and query is loading', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'test-user', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: false, + isLoading: true, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(true); + }); + + it('should return true when masqueradeUser is valid and query is successful', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'student123', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: { courses: [] }, + isError: false, + isLoading: false, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(true); + }); + }); + + describe('returns false when no masqueradeUser', () => { + it('should return false when masqueradeUser is null', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + + it('should return false when masqueradeUser is undefined', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + + it('should return false when masqueradeUser is empty string', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: '', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + + it('should return false when masqueradeUser is whitespace only', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: ' ', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(true); + }); + }); + + describe('returns false when there is an error', () => { + it('should return false when masqueradeUser exists but query has error', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'test-user', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + error: new Error('API Error'), + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + + it('should return false when masqueradeUser is valid but query fails with network error', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'admin@example.com', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + error: new Error('Network Error'), + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + + it('should return false when masqueradeUser is valid but query fails with 404 error', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'nonexistent-user', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + error: { response: { status: 404 } }, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + }); + + describe('returns false for edge cases', () => { + it('should return false when both masqueradeUser is undefined and there is an error', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + error: new Error('API Error'), + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + + it('should return false when masqueradeUser is empty string and there is an error', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: '', + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + error: new Error('API Error'), + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + + it('should return false when masqueradeUser is undefined and there is an error', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + error: new Error('API Error'), + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(result.current).toBe(false); + }); + }); + + describe('hook behavior and reactivity', () => { + it('should update when masqueradeUser changes', () => { + const { result, rerender } = renderHook(() => useIsMasquerading()); + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + rerender(); + expect(result.current).toBe(false); + + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'test-user', + setMasqueradeUser: jest.fn(), + }); + + rerender(); + expect(result.current).toBe(true); + }); + + it('should update when error state changes', () => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'test-user', + setMasqueradeUser: jest.fn(), + }); + + const { result, rerender } = renderHook(() => useIsMasquerading()); + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + rerender(); + expect(result.current).toBe(true); + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + error: new Error('API Error'), + } as any); + + rerender(); + expect(result.current).toBe(false); + }); + + it('should return boolean type consistently', () => { + const testCases = [ + { masqueradeUser: 'user', isError: false, expected: true }, + { masqueradeUser: undefined, isError: false, expected: false }, + { masqueradeUser: 'user', isError: true, expected: false }, + { masqueradeUser: '', isError: false, expected: false }, + ]; + + testCases.forEach(({ masqueradeUser, isError, expected }) => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser, + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: isError ? undefined : {}, + isError, + isLoading: false, + error: isError ? new Error('Test error') : null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + + expect(typeof result.current).toBe('boolean'); + expect(result.current).toBe(expected); + }); + }); + }); + + describe('integration scenarios', () => { + it('should work correctly in typical masquerading flow', () => { + const { result, rerender } = renderHook(() => useIsMasquerading()); + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser: jest.fn(), + }); + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + rerender(); + expect(result.current).toBe(false); + mockUseMasquerade.mockReturnValue({ + masqueradeUser: 'student123', + setMasqueradeUser: jest.fn(), + }); + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: false, + isLoading: true, + error: null, + } as any); + + rerender(); + expect(result.current).toBe(true); + mockUseInitializeLearnerHome.mockReturnValue({ + data: { courses: ['course1', 'course2'] }, + isError: false, + isLoading: false, + error: null, + } as any); + + rerender(); + expect(result.current).toBe(true); + mockUseInitializeLearnerHome.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + error: new Error('User not found'), + } as any); + + rerender(); + expect(result.current).toBe(false); + mockUseMasquerade.mockReturnValue({ + masqueradeUser: undefined, + setMasqueradeUser: jest.fn(), + }); + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + rerender(); + expect(result.current).toBe(false); + }); + + it('should handle various masqueradeUser formats', () => { + const userFormats = [ + 'username', + 'user@example.com', + 'user.name@domain.co.uk', + 'user_123', + 'User Name', + '12345', + ]; + + userFormats.forEach(user => { + mockUseMasquerade.mockReturnValue({ + masqueradeUser: user, + setMasqueradeUser: jest.fn(), + }); + + mockUseInitializeLearnerHome.mockReturnValue({ + data: {}, + isError: false, + isLoading: false, + error: null, + } as any); + + const { result } = renderHook(() => useIsMasquerading()); + expect(result.current).toBe(true); + }); + }); + }); +}); diff --git a/src/hooks/useIsMasquerading.ts b/src/hooks/useIsMasquerading.ts new file mode 100644 index 000000000..f57f89676 --- /dev/null +++ b/src/hooks/useIsMasquerading.ts @@ -0,0 +1,10 @@ +import { useMasquerade } from '@src/data/context'; +import { useInitializeLearnerHome } from '@src/data/hooks'; + +const useIsMasquerading = () => { + const { masqueradeUser } = useMasquerade(); + const { isError } = useInitializeLearnerHome(); + return !!masqueradeUser && !isError; +}; + +export default useIsMasquerading; diff --git a/src/providers.ts b/src/providers.ts index ad4400e93..7c3bd9c51 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -1,11 +1,11 @@ import { AppProvider } from '@openedx/frontend-base'; import GlobalDataProvider from './data/contexts/GlobalDataProvider'; -import MasqueradeUserProvider from './data/contexts/MasqueradeUserProvider'; +import { MasqueradeProvider } from './data/context/MasqueradeProvider'; const providers: AppProvider[] = [ GlobalDataProvider, - MasqueradeUserProvider, + MasqueradeProvider, ]; export default providers; diff --git a/src/setupTest.jsx b/src/setupTest.jsx index 08dd6febf..804e88fd0 100755 --- a/src/setupTest.jsx +++ b/src/setupTest.jsx @@ -25,21 +25,3 @@ export function initializeMockServices() { return { analyticsService, authService, loggingService }; } -jest.mock('reselect', () => ({ - createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })), -})); - -// Mock react-redux hooks -// unmock for integration tests -jest.mock('react-redux', () => { - const dispatch = jest.fn((...args) => ({ dispatch: args })).mockName('react-redux.dispatch'); - return { - connect: (mapStateToProps, mapDispatchToProps) => (component) => ({ - mapStateToProps, - mapDispatchToProps, - component, - }), - useDispatch: jest.fn(() => dispatch), - useSelector: jest.fn((selector) => ({ useSelector: selector })), - }; -}); diff --git a/src/slots/CourseBannerSlot/README.md b/src/slots/CourseBannerSlot/README.md index 87da60a19..e38a57074 100644 --- a/src/slots/CourseBannerSlot/README.md +++ b/src/slots/CourseBannerSlot/README.md @@ -17,7 +17,7 @@ The default CourseBanner looks like this when audit access has expired for the c The following configuration will render a custom implementation of a CourseBanner under every `CourseCard`. -![Screenshot of custom banner added under CourseCard](./images/course_banner_slot_default.png) +![Screenshot of custom banner added under CourseCard](./images/custom_course_banner.png) ```js import { WidgetOperationTypes } from '@openedx/frontend-base'; diff --git a/src/slots/WidgetSidebarSlot/index.test.jsx b/src/slots/WidgetSidebarSlot/index.test.jsx index 713977f24..9aba780e8 100644 --- a/src/slots/WidgetSidebarSlot/index.test.jsx +++ b/src/slots/WidgetSidebarSlot/index.test.jsx @@ -1,26 +1,24 @@ import { render, screen } from '@testing-library/react'; import { IntlProvider } from '@openedx/frontend-base'; -import { MemoryRouter } from 'react-router-dom'; -import { reduxHooks } from '@src/hooks'; +import { MemoryRouter } from 'react-router'; +import { useInitializeLearnerHome } from '@src/data/hooks'; import WidgetSidebarSlot from '.'; -jest.mock('@src/hooks', () => ({ - reduxHooks: { - usePlatformSettingsData: jest.fn(), - }, +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: jest.fn(), })); const courseSearchUrl = 'mock-url'; describe('WidgetSidebar', () => { it('renders PluginSlot with correct children', () => { - reduxHooks.usePlatformSettingsData.mockReturnValueOnce({ courseSearchUrl }); + useInitializeLearnerHome.mockReturnValueOnce({ data: { platformSettings: { courseSearchUrl } } }); render( - + , ); const pluginSlot = screen.getByText('Looking for a new challenge?'); expect(pluginSlot).toBeDefined(); diff --git a/src/test/app.test.jsx b/src/test/app.test.jsx deleted file mode 100644 index 606d70d0f..000000000 --- a/src/test/app.test.jsx +++ /dev/null @@ -1,245 +0,0 @@ -/* eslint-disable */ -import * as redux from 'redux'; -import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { - render, - waitFor, -} from '@testing-library/react'; - -import { IntlProvider } from '@openedx/frontend-base'; -import GlobalDataProvider from '@src/data/contexts/GlobalDataProvider'; -import MasqueradeUserProvider from '@src/data/contexts/MasqueradeUserProvider'; -import { useFormatDate } from '@src/utils/hooks'; -import api from '@src/data/services/lms/api'; -import * as fakeData from '@src/data/services/lms/fakeData/courses'; -import reducers from '@src/data/redux'; -import { selectors } from '@src/data/redux'; -import { cardId as genCardId } from '@src/data/redux/app/reducer'; -import messages from '@src/i18n'; -import Main from '@src/Main'; -import Inspector from './inspector'; -import appMessages from './messages'; -import { initializeMockServices } from '@src/setupTest'; - -jest.unmock('@openedx/paragon'); -jest.unmock('@openedx/paragon/icons'); -jest.unmock('react-redux'); -jest.unmock('reselect'); - -beforeAll(() => { - // Initialize the mock services including analytics - initializeMockServices(); -}); - -jest.mock('@src/slots/WidgetSidebarSlot', () => jest.fn(() => 'widget-sidebar')); - -jest.mock('@openedx/frontend-base', () => ({ - ...jest.requireActual('@openedx/frontend-base'), - sendTrackEvent: jest.fn(), - getAuthenticatedHttpClient: jest.fn(), - getLoginRedirectUrl: jest.fn(), - useIntl: () => ({ - formatMessage: jest.requireActual('../testUtils').formatMessage, - formatDate: (date) => `Date-${date}`, - }), - logError: jest.fn(), -})); - -jest.mock('@src/utils/hooks', () => { - const formatDate = jest.fn(date => `Date-${date}`); - return { - formatDate, - useFormatDate: () => formatDate, - }; -}); - -const configureStore = () => redux.createStore( - reducers, -); - -let el; -let store; -let state; -let inspector; - -const getState = () => { - state = store.getState(); - return state; -}; - -// Object to be filled with resolve/reject functions for all controlled network comm channels - -const resolveFns = { -}; - -const { compileCourseRunData } = fakeData; -const initCourses = jest.fn(() => []); - -const mockApi = () => { - api.initializeList = jest.fn(() => new Promise( - (resolve, reject) => { - resolveFns.init = { - success: () => { - const data = { - courses: initCourses(), - ...fakeData.globalData, - }; - resolve({ data }); - }, - }; - })); -}; - -const renderEl = async () => { - store = configureStore(); - - const queryClient = new QueryClient(); - el = await render( - - - - - - -
- - - - - - , - ); - getState(); -}; - -const loadApp = async (courses) => { - const compiledCourses = courses.map(compileCourseRunData); - initCourses.mockReturnValue(compiledCourses); - - await renderEl(); - inspector = new Inspector(el); - - // Since the app now uses React Query instead of Redux request states, - // we'll simulate the API response immediately and wait for rendering - resolveFns.init.success(); - - // Manually dispatch the loadCourses action since React Query might not be working in tests - const { actions } = require('../data/redux/app/reducer'); - store.dispatch(actions.loadCourses({ courses: compiledCourses })); - - // Wait for the components to render properly - await waitFor(() => { - // We should either see the loading view or the course content - const loadingView = el.container.querySelector('.course-list-loading'); - const courseContent = inspector.get.courseCards?.length > 0; - expect(loadingView || courseContent).toBeTruthy(); - }); -} - -const courseNames = [ - 'course-name-1', - 'course-name-2', - 'course-name-3', -]; - -describe('ESG app integration tests', () => { - beforeEach(() => { - mockApi(); - }); - - test('initialization', async () => { - await loadApp([{ courseName: courseNames[0] }]); - }); - - describe('course cards', () => { - const courseNames = [ - 'course-name-0', - 'course-name-1', - 'course-name-2', - ]; - const testCourse = async (index, tests) => { - await getState(); // Update the global state variable - const cards = inspector.get.courseCards; - const card = cards.at(index); - const cardId = genCardId(index); - const cardDetails = inspector.get.card.details(card); - - const courseData = selectors.app.courseCard.course(state, cardId); - if (!courseData) { - throw new Error(`Course data not found for cardId: ${cardId}`); - } - - const { courseName } = courseData; - inspector.verifyText(inspector.get.card.header(card), courseName); - if (tests.length > index) { - tests[index]({ cardId, cardDetails }); - } - } - - const loadCourse = async (course) => { - await loadApp([course].map(compileCourseRunData)); - }; - - describe('audit courses', () => { - test('audit', async () => { - const courses = [ - { courseName: courseNames[0] }, // audit, course run not started - { - courseName: courseNames[1], - enrollment: { - coursewareAccess: { - isTooEarly: true, - hasUnmetPrerequisites: false, - isStaff: false, - }, - }, - }, // audit, course run not started, is too early - { - courseName: courseNames[2], - courseRun: { - courseRun: { isStarted: true }, - }, - enrollment: { - accessExpirationDate: fakeData.pastDate, - canUpgrade: false, - isAuditAccessExpired: true, - hasStarted: true, - }, - }, // audit, course run and learner started, access expired, cannot upgrade - ]; - const formatDate = useFormatDate(); - await loadApp(courses); - await testCourse(0, [ - ({ cardId, cardDetails }) => { - const enrollment = selectors.app.courseCard.enrollment(state, cardId); - const courseRun = selectors.app.courseCard.courseRun(state, cardId); - const courseProvider = selectors.app.courseCard.courseProvider(state, cardId); - const course = selectors.app.courseCard.course(state, cardId); - expect(enrollment.isAudit).toEqual(true); - expect(courseRun.isStarted).toEqual(false); - expect(enrollment.canUpgrade).toEqual(true); - [ - courseProvider.name, - course.courseNumber, - appMessages.withValues.CourseCardDetails.courseStarts({ - startDate: formatDate(new Date(courseRun.startDate)), - }), - ].forEach(value => inspector.verifyTextIncludes(cardDetails, value)); - }, - ]); - await testCourse(1, [ - ({ cardId, cardDetails }) => { - const enrollment = selectors.app.courseCard.enrollment(state, cardId); - const courseRun = selectors.app.courseCard.courseRun(state, cardId); - expect(enrollment.isAudit).toEqual(true); - expect(courseRun.isStarted).toEqual(false); - expect(enrollment.coursewareAccess.isTooEarly).toEqual(true); - expect(enrollment.hasAccess).toEqual(false); - }, - ]); - }); - }); - }); -}); diff --git a/src/test/inspector.js b/src/test/inspector.js deleted file mode 100644 index 4bbb3c380..000000000 --- a/src/test/inspector.js +++ /dev/null @@ -1,44 +0,0 @@ -import { within } from '@testing-library/react'; - -/** - * App inspector class providing methods to return elements from within - * the virtual DOM - * @props {Root Node} el - Root app render node. - */ -class Inspector { - constructor(el) { - this.el = el; - this.getByRole = this.el.getByRole; - this.getByText = this.el.getByText; - this.getByLabelText = this.el.getByLabelText; - this.findByText = this.el.findByText; - this.findByLabelText = this.el.findByLabelText; - } - - get get() { - return { - courseCards: this.el.getAllByTestId('CourseCard'), - card: { - header: (card) => within(card).getByTestId('CourseCardTitle'), - details: (card) => within(card).getByTestId('CourseCardDetails'), - // banners: (card) => within(card).getByTestId('CourseCardBanners'), - // programsBadge: (card) => within(card).getByTestId('RelatedProgramsBadge'), - // actions: (card) => within(card).getByTestId('CourseCardActions'), - }, - }; - } - - /** - * Returns promises for attempting to find elements within the DOM - */ - get find() { - return { - }; - } - - verifyText = (el, text) => within(el).getByText(text); - - verifyTextIncludes = (el, text) => within(el).getByText(text, { exact: false }); -} - -export default Inspector; diff --git a/src/test/messages.js b/src/test/messages.js deleted file mode 100644 index c211fe432..000000000 --- a/src/test/messages.js +++ /dev/null @@ -1,29 +0,0 @@ -import CourseCardDetails from '@src/containers/CourseCard/components/CourseCardDetails/messages'; - -const mapMessages = (messages) => Object.keys(messages).reduce( - (acc, key) => ({ ...acc, [key]: messages[key].defaultMessage }), - {}, -); - -const mapMessagesWithValues = (messages) => Object.keys(messages).reduce( - (acc, key) => ({ - ...acc, - [key]: (values) => { - let message = messages[key].defaultMessage; - if (values) { - Object.keys(values).forEach(valueKey => { - message = message.replaceAll(`{${valueKey}}`, values[valueKey]); - }); - } - return message; - }, - }), - {}, -); - -export default { - CourseCardDetails: mapMessages(CourseCardDetails), - withValues: { - CourseCardDetails: mapMessagesWithValues(CourseCardDetails), - }, -}; diff --git a/src/test/utils.js b/src/test/utils.js deleted file mode 100644 index 7eba5e312..000000000 --- a/src/test/utils.js +++ /dev/null @@ -1,3 +0,0 @@ -export const mockSuccess = (returnValFn) => (...args) => Promise.resolve(returnValFn(...args)); - -export const mockFailure = (returnValFn) => (...args) => Promise.reject(returnValFn(...args)); diff --git a/src/tracking/trackers/socialShare.js b/src/tracking/trackers/socialShare.js index a7dcfe95c..75920ccc2 100644 --- a/src/tracking/trackers/socialShare.js +++ b/src/tracking/trackers/socialShare.js @@ -1,4 +1,4 @@ -import api from '../../data/services/lms/api'; +import { logShare } from '@src/data/services/lms/api'; /** * Track Social Share event click. @@ -6,6 +6,6 @@ import api from '../../data/services/lms/api'; * @param {string} site - sharing destination ('facebook', 'twitter') * @return {func} - Callback that tracks the event when fired. */ -export const shareClicked = (courseId, site) => () => api.logShare({ courseId, site }); +export const shareClicked = (courseId, site) => () => logShare({ courseId, site }); export default shareClicked; diff --git a/src/utils/dataTransformers.test.ts b/src/utils/dataTransformers.test.ts new file mode 100644 index 000000000..434f200d4 --- /dev/null +++ b/src/utils/dataTransformers.test.ts @@ -0,0 +1,629 @@ +import { FilterKeys, SortKeys, ListPageSize } from '@src/data/constants/app'; +import { + getVisibleList, + getTransformedCourseDataList, + getTransformedCourseDataObject, +} from './dataTransformers'; + +const mockGet = jest.fn(); + +Object.defineProperty(window, 'location', { + value: { + search: '', + }, + writable: true, +}); + +// Mock URLSearchParams constructor +Object.defineProperty(globalThis, 'URLSearchParams', { + value: jest.fn().mockImplementation(() => ({ + get: mockGet, + })), + writable: true, +}); + +interface VisibleListResult { + visibleList: { + course: { courseName: string }, + enrollment: { lastEnrolled: Date }, + }[], + numPages: number, +} + +describe('dataTransformers', () => { + const mockCourses = [ + { + course: { + courseName: 'Introduction to React', + courseNumber: 'CS101', + }, + enrollment: { + isEnrolled: true, + isVerified: false, + hasStarted: true, + lastEnrolled: '2024-01-15T00:00:00Z', + }, + courseRun: { + isArchived: false, + }, + }, + { + course: { + courseName: 'Advanced JavaScript', + courseNumber: 'CS201', + }, + enrollment: { + isEnrolled: true, + isVerified: true, + hasStarted: false, + lastEnrolled: '2024-02-01T00:00:00Z', + }, + courseRun: { + isArchived: false, + }, + }, + { + course: { + courseName: 'Data Structures', + courseNumber: 'CS301', + }, + enrollment: { + isEnrolled: false, + isVerified: false, + hasStarted: false, + lastEnrolled: null, + }, + courseRun: null, + }, + { + course: { + courseName: 'Algorithms', + courseNumber: 'CS401', + }, + enrollment: { + isEnrolled: true, + isVerified: false, + hasStarted: true, + lastEnrolled: '2024-01-01T00:00:00Z', + }, + courseRun: { + isArchived: true, + }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockGet.mockReturnValue(null); + window.location.search = ''; + }); + + describe('getTransformedCourseDataObject', () => { + it('should transform courses array into object with cardId keys', () => { + const result = getTransformedCourseDataObject(mockCourses); + + expect(Object.keys(result)).toEqual(['card-0', 'card-1', 'card-2', 'card-3']); + expect(result['card-0']).toMatchObject({ + ...mockCourses[0], + cardId: 'card-0', + }); + expect(result['card-1']).toMatchObject({ + ...mockCourses[1], + cardId: 'card-1', + }); + }); + + it('should add current timestamp to lastEnrolled when null', () => { + const coursesWithNullEnrollment = [ + { + course: { courseName: 'Test Course' }, + enrollment: { + lastEnrolled: null, + isEnrolled: true, + }, + }, + ]; + + const result = getTransformedCourseDataObject(coursesWithNullEnrollment); + const transformedCourse = result['card-0']; + + expect(transformedCourse.enrollment.lastEnrolled).toBeGreaterThan(0); + expect(typeof transformedCourse.enrollment.lastEnrolled).toBe('number'); + }); + + it('should preserve existing lastEnrolled timestamp', () => { + const existingTimestamp = '2024-01-15T00:00:00Z'; + const coursesWithTimestamp = [ + { + enrollment: { + lastEnrolled: existingTimestamp, + }, + }, + ]; + + const result = getTransformedCourseDataObject(coursesWithTimestamp); + + expect(result['card-0'].enrollment.lastEnrolled).toBe(existingTimestamp); + }); + + it('should handle empty courses array', () => { + const result = getTransformedCourseDataObject([]); + + expect(result).toEqual({}); + expect(Object.keys(result)).toHaveLength(0); + }); + + it('should handle courses without enrollment property', () => { + const coursesWithoutEnrollment = [ + { + course: { courseName: 'Test Course' }, + }, + ]; + + const result = getTransformedCourseDataObject(coursesWithoutEnrollment); + + expect(result['card-0']).toMatchObject({ + course: { courseName: 'Test Course' }, + cardId: 'card-0', + }); + }); + + it('should generate sequential cardId for multiple courses', () => { + const manyCourses = Array.from({ length: 5 }, (_, i) => ({ + course: { courseName: `Course ${i}` }, + })); + + const result = getTransformedCourseDataObject(manyCourses); + + expect(Object.keys(result)).toEqual([ + 'card-0', 'card-1', 'card-2', 'card-3', 'card-4', + ]); + }); + }); + + describe('getTransformedCourseDataList', () => { + it('should transform courses array into array with cardId properties', () => { + const result = getTransformedCourseDataList(mockCourses); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(4); + expect(result[0]).toMatchObject({ + ...mockCourses[0], + cardId: 'card-0', + }); + expect(result[1]).toMatchObject({ + ...mockCourses[1], + cardId: 'card-1', + }); + }); + + it('should return empty array for empty input', () => { + const result = getTransformedCourseDataList([]); + + expect(result).toEqual([]); + }); + + it('should maintain course order', () => { + interface MockCourseType { + course: { courseName: string }, + } + const result = getTransformedCourseDataList(mockCourses) as MockCourseType[]; + + expect(result[0].course.courseName).toBe('Introduction to React'); + expect(result[1].course.courseName).toBe('Advanced JavaScript'); + expect(result[2].course.courseName).toBe('Data Structures'); + expect(result[3].course.courseName).toBe('Algorithms'); + }); + }); + + describe('getVisibleList', () => { + const transformedCourses = [ + { + course: { courseName: 'Introduction to React' }, + enrollment: { + isEnrolled: true, + isVerified: false, + hasStarted: true, + lastEnrolled: new Date('2024-01-15'), + }, + courseRun: { isArchived: false }, + }, + { + course: { courseName: 'Advanced JavaScript' }, + enrollment: { + isEnrolled: true, + isVerified: true, + hasStarted: false, + lastEnrolled: new Date('2024-02-01'), + }, + courseRun: { isArchived: false }, + }, + { + course: { courseName: 'Data Structures' }, + enrollment: { + isEnrolled: false, + isVerified: false, + hasStarted: false, + lastEnrolled: new Date('2024-01-10'), + }, + courseRun: null, + }, + { + course: { courseName: 'Algorithms' }, + enrollment: { + isEnrolled: true, + isVerified: false, + hasStarted: true, + lastEnrolled: new Date('2024-01-01'), + }, + courseRun: { isArchived: true }, + }, + ]; + + describe('filtering', () => { + it('should filter courses by notEnrolled', () => { + const result = getVisibleList( + transformedCourses, + [FilterKeys.notEnrolled], + SortKeys.title, + 1, + ) as VisibleListResult; + + expect(result.visibleList).toHaveLength(1); + expect(result.visibleList[0].course.courseName).toBe('Data Structures'); + }); + + it('should filter courses by done (archived)', () => { + const result = getVisibleList( + transformedCourses, + [FilterKeys.done], + SortKeys.title, + 1, + ) as VisibleListResult; + + expect(result.visibleList).toHaveLength(1); + expect(result.visibleList[0].course.courseName).toBe('Algorithms'); + }); + + it('should filter courses by upgraded (verified)', () => { + const result = getVisibleList( + transformedCourses, + [FilterKeys.upgraded], + SortKeys.title, + 1, + ) as VisibleListResult; + + expect(result.visibleList).toHaveLength(1); + expect(result.visibleList[0].course.courseName).toBe('Advanced JavaScript'); + }); + + it('should filter courses by inProgress (hasStarted)', () => { + const result = getVisibleList( + transformedCourses, + [FilterKeys.inProgress], + SortKeys.title, + 1, + ) as VisibleListResult; + + expect(result.visibleList).toHaveLength(2); + const courseNames = result.visibleList.map(c => c.course.courseName).sort(); + expect(courseNames).toEqual(['Algorithms', 'Introduction to React']); + }); + + it('should filter courses by notStarted', () => { + const result = getVisibleList( + transformedCourses, + [FilterKeys.notStarted], + SortKeys.title, + 1, + ) as VisibleListResult; + + expect(result.visibleList).toHaveLength(2); + const courseNames = result.visibleList.map(c => c.course.courseName).sort(); + expect(courseNames).toEqual(['Advanced JavaScript', 'Data Structures']); + }); + + it('should apply multiple filters with AND logic', () => { + const result = getVisibleList( + transformedCourses, + [FilterKeys.upgraded, FilterKeys.notStarted], + SortKeys.title, + 1, + ) as VisibleListResult; + + expect(result.visibleList).toHaveLength(1); + expect(result.visibleList[0].course.courseName).toBe('Advanced JavaScript'); + }); + + it('should return all courses when no filters applied', () => { + const result = getVisibleList( + transformedCourses, + [], + SortKeys.title, + 1, + ); + + expect(result.visibleList).toHaveLength(4); + }); + + it('should return empty list when filters match no courses', () => { + const result = getVisibleList( + transformedCourses, + [FilterKeys.notEnrolled, FilterKeys.upgraded], + SortKeys.title, + 1, + ); + + expect(result.visibleList).toHaveLength(0); + }); + }); + + describe('sorting', () => { + it('should sort by title (alphabetically)', () => { + const result = getVisibleList( + transformedCourses, + [], + SortKeys.title, + 1, + ) as VisibleListResult; + + const sortedNames = result.visibleList.map(c => c.course.courseName); + expect(sortedNames).toEqual([ + 'Advanced JavaScript', + 'Algorithms', + 'Data Structures', + 'Introduction to React', + ]); + }); + + it('should sort by enrolled date (newest first - reverse order)', () => { + const result = getVisibleList( + transformedCourses, + [], + SortKeys.enrolled, + 1, + ) as VisibleListResult; + + const enrolledDates = result.visibleList.map(c => c.enrollment.lastEnrolled); + expect(enrolledDates[0]).toEqual(new Date('2024-02-01')); + expect(enrolledDates[1]).toEqual(new Date('2024-01-15')); + expect(enrolledDates[2]).toEqual(new Date('2024-01-10')); + expect(enrolledDates[3]).toEqual(new Date('2024-01-01')); + }); + + it('should handle courses with identical sort values', () => { + const identicalCourses = [ + { + course: { courseName: 'Same Name' }, + enrollment: { lastEnrolled: new Date('2024-01-01') }, + }, + { + course: { courseName: 'Same Name' }, + enrollment: { lastEnrolled: new Date('2024-01-01') }, + }, + ]; + + const result = getVisibleList( + identicalCourses, + [], + SortKeys.title, + 1, + ); + + expect(result.visibleList).toHaveLength(2); + }); + }); + + describe('pagination', () => { + const manyCourses = Array.from({ length: 25 }, (_, i) => ({ + course: { courseName: `Course ${i.toString().padStart(2, '0')}` }, + enrollment: { + isEnrolled: true, + hasStarted: false, + lastEnrolled: new Date(`2024-01-${(i + 1).toString().padStart(2, '0')}`), + }, + courseRun: { isArchived: false }, + })); + + it('should paginate results correctly for first page', () => { + const result = getVisibleList( + manyCourses, + [], + SortKeys.title, + 1, + ) as VisibleListResult; + + expect(result.visibleList).toHaveLength(ListPageSize); + expect(result.numPages).toBe(Math.ceil(25 / ListPageSize)); + expect(result.visibleList[0].course.courseName).toBe('Course 00'); + }); + + it('should paginate results correctly for second page', () => { + const listSize = 50; + const manyCoursesList = Array.from({ length: listSize }, (_, i) => ({ + course: { courseName: `Course ${i.toString().padStart(2, '0')}` }, + })); + const result = getVisibleList( + manyCoursesList, + [], + SortKeys.title, + 2, + ) as VisibleListResult; + expect(result.visibleList).toHaveLength(listSize - ListPageSize); + const expectedFirstCourse = `Course ${ListPageSize.toString().padStart(2, '0')}`; + expect(result.visibleList[0].course.courseName).toBe(expectedFirstCourse); + }); + + it('should handle last page with fewer items', () => { + const result = getVisibleList( + manyCourses, + [], + SortKeys.title, + Math.ceil(25 / ListPageSize), + ); + + expect(result.visibleList).toHaveLength(25 % ListPageSize || ListPageSize); + }); + + it('should calculate correct number of pages', () => { + const result = getVisibleList( + manyCourses, + [], + SortKeys.title, + 1, + ); + + expect(result.numPages).toBe(Math.ceil(25 / ListPageSize)); + }); + + it('should disable pagination when query parameter is set', () => { + mockGet.mockReturnValue('1'); + + const result = getVisibleList( + manyCourses, + [], + SortKeys.title, + 1, + ); + + expect(result.visibleList).toHaveLength(25); + expect(result.numPages).toBe(1); + }); + + it('should use pagination when disable_pagination is not 1', () => { + mockGet.mockReturnValue('0'); + + const result = getVisibleList( + manyCourses, + [], + SortKeys.title, + 1, + ); + + expect(result.visibleList).toHaveLength(ListPageSize); + }); + + it('should handle empty courses array with pagination', () => { + const result = getVisibleList( + [], + [], + SortKeys.title, + 1, + ); + + expect(result.visibleList).toHaveLength(0); + expect(result.numPages).toBe(0); + }); + }); + + describe('edge cases', () => { + it('should handle courses with missing properties', () => { + const coursesWithMissingProps = [ + { + course: { courseName: 'Course 1' }, + enrollment: {}, // Missing properties + courseRun: null, + }, + ]; + + expect(() => { + getVisibleList( + coursesWithMissingProps, + [FilterKeys.inProgress], + SortKeys.title, + 1, + ); + }).not.toThrow(); + }); + + it('should handle invalid page number', () => { + const result = getVisibleList( + transformedCourses, + [], + SortKeys.title, + 0, + ); + + // Should handle gracefully, possibly returning empty or adjusting + expect(result.visibleList).toBeDefined(); + }); + + it('should handle very large page number', () => { + const result = getVisibleList( + transformedCourses, + [], + SortKeys.title, + 999, + ); + + expect(result.visibleList).toHaveLength(0); + }); + + it('should handle null courseRun in done filter', () => { + const coursesWithNullCourseRun = [ + { + course: { courseName: 'Test Course' }, + enrollment: { isEnrolled: true }, + courseRun: null, + }, + ]; + + const result = getVisibleList( + coursesWithNullCourseRun, + [FilterKeys.done], + SortKeys.title, + 1, + ); + + expect(result.visibleList).toHaveLength(0); + }); + }); + + describe('integration scenarios', () => { + it('should handle complex filtering, sorting, and pagination together', () => { + const result = getVisibleList( + transformedCourses, + [FilterKeys.inProgress], + SortKeys.enrolled, + 1, + ) as VisibleListResult; + + // Should get in-progress courses, sorted by enrollment date (newest first) + expect(result.visibleList).toHaveLength(2); + expect(result.visibleList[0].course.courseName).toBe('Introduction to React'); + expect(result.visibleList[1].course.courseName).toBe('Algorithms'); + }); + + it('should maintain functionality with realistic course data structure', () => { + const realisticCourses = [ + { + course: { + courseName: 'Introduction to Computer Science', + courseNumber: 'CS50', + }, + enrollment: { + isEnrolled: true, + isVerified: true, + hasStarted: true, + lastEnrolled: new Date('2024-01-15'), + }, + courseRun: { + isArchived: false, + startDate: '2024-02-01', + }, + }, + ]; + + const result = getVisibleList( + realisticCourses, + [], + SortKeys.title, + 1, + ) as VisibleListResult; + + expect(result.visibleList).toHaveLength(1); + expect(result.visibleList[0].course.courseName).toBe('Introduction to Computer Science'); + }); + }); + }); +}); diff --git a/src/utils/dataTransformers.ts b/src/utils/dataTransformers.ts new file mode 100644 index 000000000..73d35de17 --- /dev/null +++ b/src/utils/dataTransformers.ts @@ -0,0 +1,71 @@ +import { FilterKeys, ListPageSize, SortKeys } from '@src/data/constants/app'; +import StrictDict from './StrictDict'; + +const cardId = (val) => `card-${val}`; + +const transformCourseData = (courses) => { + const now = Date.now(); + return courses.reduce( + (obj, curr, index) => { + const out = { ...curr, cardId: cardId(index) }; + if (out.enrollment?.lastEnrolled === null) { + out.enrollment.lastEnrolled = now; + } + return { ...obj, [cardId(index)]: out }; + }, + {}, + ); +}; + +const getTransformedCourseDataObject = (courses) => transformCourseData(courses); + +const getTransformedCourseDataList = (courses) => Object.values(transformCourseData(courses)); + +const getVisibleList = (courses: any[], filters: string[], sortBy: string, pageNumber: number) => { + const courseFilters = StrictDict({ + [FilterKeys.notEnrolled]: (course) => !course.enrollment.isEnrolled, + [FilterKeys.done]: (course) => course.courseRun !== null && course.courseRun.isArchived, + [FilterKeys.upgraded]: (course) => course.enrollment.isVerified, + [FilterKeys.inProgress]: (course) => course.enrollment.hasStarted, + [FilterKeys.notStarted]: (course) => !course.enrollment.hasStarted, + }); + + const transforms = StrictDict({ + [SortKeys.enrolled]: ({ enrollment }) => new Date(enrollment?.lastEnrolled), + [SortKeys.title]: ({ course }) => course.courseName.toLowerCase(), + }); + + const courseFilterFn = filtersList => (filtersList.length + ? course => filtersList.reduce((match, filter) => match && courseFilters[filter](course), true) + : () => true); + + const sortFn = (transform, { reverse }) => (v1, v2) => { + const [a, b] = [v1, v2].map(transform); + if (a === b) { + return 0; + } + return (((a as any) > (b as any)) ? 1 : -1) * (reverse ? -1 : 1); + }; + + const list = courses + .filter(courseFilterFn(filters)) + .sort(sortFn(transforms[sortBy], { reverse: sortBy === SortKeys.enrolled })); + + const querySearch = new URLSearchParams(window.location.search); + const disablePagination = querySearch.get('disable_pagination'); + const pageSize = Number(disablePagination) === 1 ? 0 : ListPageSize; + + if (pageSize === 0) { + return { + visibleList: list, + numPages: 1, + }; + } + const result = { + visibleList: list.slice((pageNumber - 1) * pageSize, pageNumber * pageSize), + numPages: Math.ceil(list.length / pageSize), + }; + return result; +}; + +export { getVisibleList, getTransformedCourseDataList, getTransformedCourseDataObject }; diff --git a/src/utils/hooks.test.tsx b/src/utils/hooks.test.tsx new file mode 100644 index 000000000..fc62e934d --- /dev/null +++ b/src/utils/hooks.test.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react'; +import { render } from '@testing-library/react'; +import { IntlProvider } from '@openedx/frontend-base'; +import { useFormatDate } from './hooks'; +import dateFormatter from './dateFormatter'; + +jest.mock('./dateFormatter'); +const TestComponent = ({ date }: { date: Date | string }) => { + const formatDate = useFormatDate(); + const formattedDate = formatDate(date); + + return
{formattedDate}
; +}; + +const renderWithIntl = (component: ReactNode) => render( + + {component} + , +); + +describe('useFormatDate hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call dateFormatter with formatDate function and date', () => { + const mockDate = new Date('2024-01-15'); + const mockFormattedDate = 'January 15, 2024'; + + (dateFormatter as jest.Mock).mockReturnValue(mockFormattedDate); + + const { getByText } = renderWithIntl(); + + expect(dateFormatter).toHaveBeenCalledWith( + expect.any(Function), + mockDate, + ); + expect(getByText(mockFormattedDate)).toBeInTheDocument(); + }); + + it('should handle different date formats', () => { + const stringDate = '2024-12-25'; + const mockFormattedDate = 'December 25, 2024'; + + (dateFormatter as jest.Mock).mockReturnValue(mockFormattedDate); + + const { getByText } = renderWithIntl(); + + expect(dateFormatter).toHaveBeenCalledWith( + expect.any(Function), + stringDate, + ); + expect(getByText(mockFormattedDate)).toBeInTheDocument(); + }); +}); diff --git a/src/widgets/LearnerDashboardHeader/ConfirmEmailBanner/hooks.js b/src/widgets/LearnerDashboardHeader/ConfirmEmailBanner/hooks.js index dcf7d49e5..3ef6ee84f 100644 --- a/src/widgets/LearnerDashboardHeader/ConfirmEmailBanner/hooks.js +++ b/src/widgets/LearnerDashboardHeader/ConfirmEmailBanner/hooks.js @@ -1,25 +1,17 @@ -import { useContext, useState } from 'react'; +import { useState } from 'react'; -import GlobalDataContext from '../../../data/contexts/GlobalDataContext'; -import { StrictDict } from '../../../utils'; -import { apiHooks } from '../../../hooks'; - -import * as module from './hooks'; - -export const state = StrictDict({ - showPageBanner: (val) => useState(val), // eslint-disable-line - showConfirmModal: (val) => useState(val), // eslint-disable-line -}); +import { useInitializeLearnerHome, useSendConfirmEmail } from '@src/data/hooks'; export const useConfirmEmailBannerData = () => { - const { emailConfirmation } = useContext(GlobalDataContext); - const { isNeeded } = emailConfirmation; - const [showPageBanner, setShowPageBanner] = module.state.showPageBanner(isNeeded); - const [showConfirmModal, setShowConfirmModal] = module.state.showConfirmModal(false); + const { data: learnerData } = useInitializeLearnerHome(); + const isNeeded = learnerData?.emailConfirmation?.isNeeded || false; + const sendEmailUrl = learnerData?.emailConfirmation?.sendEmailUrl || ''; + const { mutate: sendConfirmEmail } = useSendConfirmEmail(sendEmailUrl); + const [showPageBanner, setShowPageBanner] = useState(isNeeded); + const [showConfirmModal, setShowConfirmModal] = useState(false); const closePageBanner = () => setShowPageBanner(false); const closeConfirmModal = () => setShowConfirmModal(false); const openConfirmModal = () => setShowConfirmModal(true); - const sendConfirmEmail = apiHooks.useSendConfirmEmail(); const openConfirmModalButtonClick = () => { sendConfirmEmail(); diff --git a/src/widgets/LearnerDashboardHeader/ConfirmEmailBanner/hooks.test.jsx b/src/widgets/LearnerDashboardHeader/ConfirmEmailBanner/hooks.test.jsx new file mode 100644 index 000000000..934048f25 --- /dev/null +++ b/src/widgets/LearnerDashboardHeader/ConfirmEmailBanner/hooks.test.jsx @@ -0,0 +1,111 @@ +import { renderHook, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useInitializeLearnerHome } from '@src/data/hooks'; +import * as api from '@src/data/services/lms/api'; +import * as hooks from './hooks'; + +jest.mock('@src/data/hooks', () => ({ + ...jest.requireActual('@src/data/hooks'), + useInitializeLearnerHome: jest.fn(), +})); + +jest.mock('@src/data/services/lms/api', () => ({ + sendConfirmEmail: jest.fn(), +})); + +const emailConfirmation = { + isNeeded: true, +}; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }) => {children}; + return wrapper; +}; + +describe('ConfirmEmailBanner hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + api.sendConfirmEmail.mockResolvedValue({}); + }); + + describe('useEmailConfirmationData', () => { + it('show page banner on unverified email', () => { + useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { ...emailConfirmation } } }); + + const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), { + wrapper: createWrapper(), + }); + + expect(result.current.isNeeded).toEqual(emailConfirmation.isNeeded); + }); + + it('hide page banner on verified email', () => { + useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { isNeeded: false } } }); + + const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), { + wrapper: createWrapper(), + }); + + expect(result.current.isNeeded).toEqual(false); + }); + }); + + describe('behavior', () => { + beforeEach(() => { + useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { ...emailConfirmation } } }); + }); + + it('closePageBanner', () => { + const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.closePageBanner(); + }); + + expect(result.current.showPageBanner).toEqual(false); + }); + + it('closeConfirmModal', () => { + const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.closeConfirmModal(); + }); + + expect(result.current.showConfirmModal).toEqual(false); + }); + + it('openConfirmModalButtonClick', async () => { + const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), { + wrapper: createWrapper(), + }); + + await act(async () => { + result.current.openConfirmModalButtonClick(); + }); + + expect(result.current.showConfirmModal).toEqual(true); + expect(api.sendConfirmEmail).toHaveBeenCalled(); + }); + + it('userConfirmEmailButtonClick', () => { + const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.userConfirmEmailButtonClick(); + }); + + expect(result.current.showConfirmModal).toEqual(false); + expect(result.current.showPageBanner).toEqual(false); + }); + }); +}); diff --git a/src/widgets/LearnerDashboardHeader/MasqueradeBar/hooks.js b/src/widgets/LearnerDashboardHeader/MasqueradeBar/hooks.js index 42c364407..cc4956e4c 100644 --- a/src/widgets/LearnerDashboardHeader/MasqueradeBar/hooks.js +++ b/src/widgets/LearnerDashboardHeader/MasqueradeBar/hooks.js @@ -1,62 +1,46 @@ -import { useState, useContext } from 'react'; +import { useState, useMemo } from 'react'; import { useIntl } from '@openedx/frontend-base'; -import MasqueradeUserContext from '../../../data/contexts/MasqueradeUserContext'; -import { StrictDict } from '../../../utils'; +import { useMasquerade } from '../../../data/context'; +import { useInitializeLearnerHome } from '../../../data/hooks'; -import * as module from './hooks'; import messages from './messages'; -export const state = StrictDict({ - masqueradeInput: (val) => useState(val), // eslint-disable-line -}); - -export const useMasqueradeInput = () => { - const [masqueradeInput, setMasqueradeInput] = module.state.masqueradeInput(''); - const handleMasqueradeInputChange = (e) => setMasqueradeInput(e.target.value); - return { - handleMasqueradeInputChange, - masqueradeInput, - }; -}; - -const masqueradeErrorMessageMap = { - 404: messages.NoStudentFound, -}; - -export const getMasqueradeErrorMessage = (errorStatus) => { - if (errorStatus == null) { - return null; - } - return masqueradeErrorMessageMap[errorStatus] ?? messages.UnknownError; -}; - export const useMasqueradeBarData = ({ authenticatedUser, }) => { const { formatMessage } = useIntl(); - const { setMasqueradeUser } = useContext(MasqueradeUserContext); - const handleClearMasquerade = () => setMasqueradeUser(null); - + const [masqueradeInput, setMasqueradeInput] = useState(''); + const { masqueradeUser, setMasqueradeUser } = useMasquerade(); const { - masqueradeIsSuccess, - masqueradeIsPending, - masqueradeIsError, - masqueradeError, - } = useContext(MasqueradeUserContext); - const { masqueradeInput, handleMasqueradeInputChange } = module.useMasqueradeInput(); + isError, error, isPending, + } = useInitializeLearnerHome(); - const masqueradeErrorMessage = getMasqueradeErrorMessage(masqueradeError?.customAttributes?.httpErrorStatus); + const handleMasqueradeInputChange = (e) => setMasqueradeInput(e.target.value); + const handleClearMasquerade = () => { + setMasqueradeUser(undefined); + setMasqueradeInput(''); + }; const handleMasqueradeSubmit = (user) => (e) => { setMasqueradeUser(user); e.preventDefault(); }; + const isMasqueradingFailed = !!masqueradeUser && !!masqueradeInput && isError; + const isMasqueradingPending = !!masqueradeUser && isPending; + const isMasquerading = !!masqueradeUser && !isError && !isPending; + const masqueradeErrorMessage = useMemo(() => { + if (masqueradeUser && error) { + return (error.customAttributes?.httpErrorStatus === 404 ? messages.NoStudentFound : messages.UnknownError); + } + return null; + }, [error, masqueradeUser]); + return { canMasquerade: authenticatedUser?.administrator, - isMasquerading: masqueradeIsSuccess, - isMasqueradingFailed: masqueradeIsError, - isMasqueradingPending: masqueradeIsPending, + isMasquerading, + isMasqueradingFailed, + isMasqueradingPending, masqueradeErrorMessage, masqueradeInput, handleMasqueradeSubmit, diff --git a/src/widgets/LearnerDashboardHeader/MasqueradeBar/index.jsx b/src/widgets/LearnerDashboardHeader/MasqueradeBar/index.jsx index 04da3e9d6..87c23eb12 100644 --- a/src/widgets/LearnerDashboardHeader/MasqueradeBar/index.jsx +++ b/src/widgets/LearnerDashboardHeader/MasqueradeBar/index.jsx @@ -72,7 +72,7 @@ export const MasqueradeBar = () => { )} ); export const LookingForChallengeWidget = () => { const { formatMessage } = useIntl(); - const { courseSearchUrl } = reduxHooks.usePlatformSettingsData(); + const { data: learnerData } = useInitializeLearnerHome(); + const courseSearchUrl = learnerData?.platformSettings?.courseSearchUrl || ''; const hyperlinkDestination = baseAppUrl(courseSearchUrl) || ''; return ( diff --git a/src/widgets/LookingForChallengeWidget/index.test.jsx b/src/widgets/LookingForChallengeWidget/index.test.jsx index e4e7ed8ae..5f98ecb54 100644 --- a/src/widgets/LookingForChallengeWidget/index.test.jsx +++ b/src/widgets/LookingForChallengeWidget/index.test.jsx @@ -5,12 +5,14 @@ import messages from './messages'; const courseSearchUrl = 'http://localhost:18000/course-search-url'; -jest.mock('@src/hooks', () => ({ - reduxHooks: { - usePlatformSettingsData: () => ({ - courseSearchUrl, - }), - }, +jest.mock('@src/data/hooks', () => ({ + useInitializeLearnerHome: () => ({ + data: { + platformSettings: { + courseSearchUrl, + }, + }, + }), })); jest.mock('./track', () => ({ diff --git a/tsconfig.json b/tsconfig.json index b1a28c5bc..bc6862d0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@openedx/frontend-base/tools/tsconfig.json", "compilerOptions": { + "types": ["jest", "@testing-library/jest-dom"], "rootDir": ".", "outDir": "dist", "paths": {