diff --git a/workspaces/frontend/jest.config.js b/workspaces/frontend/jest.config.js index e4cfe6ef..e8e27cc6 100644 --- a/workspaces/frontend/jest.config.js +++ b/workspaces/frontend/jest.config.js @@ -33,6 +33,10 @@ module.exports = { // A list of paths to snapshot serializer modules Jest should use for snapshot testing snapshotSerializers: [], + setupFilesAfterEnv: ['/src/__tests__/unit/jest.setup.ts'], + + preset: 'ts-jest', + coverageDirectory: 'jest-coverage', collectCoverageFrom: [ diff --git a/workspaces/frontend/package-lock.json b/workspaces/frontend/package-lock.json index ece690d1..5d23d7b0 100644 --- a/workspaces/frontend/package-lock.json +++ b/workspaces/frontend/package-lock.json @@ -22,6 +22,7 @@ "devDependencies": { "@cypress/code-coverage": "^3.13.5", "@testing-library/cypress": "^10.0.1", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "14.4.3", @@ -32,6 +33,7 @@ "chai-subset": "^1.6.0", "concurrently": "^9.1.0", "copy-webpack-plugin": "^11.0.0", + "core-js": "^3.39.0", "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", "cypress": "^13.15.0", @@ -41,6 +43,7 @@ "cypress-multi-reporters": "^2.0.4", "dotenv": "^16.4.5", "dotenv-webpack": "^8.1.0", + "expect": "^29.7.0", "html-webpack-plugin": "^5.6.0", "imagemin": "^8.0.1", "jest": "^29.7.0", @@ -3451,7 +3454,7 @@ "cypress": "^12.0.0 || ^13.0.0" } }, - "node_modules/@testing-library/cypress/node_modules/@testing-library/dom": { + "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", @@ -3471,12 +3474,11 @@ "node": ">=18" } }, - "node_modules/@testing-library/cypress/node_modules/ansi-styles": { + "node_modules/@testing-library/dom/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3487,12 +3489,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@testing-library/cypress/node_modules/chalk": { + "node_modules/@testing-library/dom/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3504,12 +3505,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@testing-library/cypress/node_modules/color-convert": { + "node_modules/@testing-library/dom/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3517,29 +3517,26 @@ "node": ">=7.0.0" } }, - "node_modules/@testing-library/cypress/node_modules/color-name": { + "node_modules/@testing-library/dom/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/@testing-library/cypress/node_modules/has-flag": { + "node_modules/@testing-library/dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@testing-library/cypress/node_modules/pretty-format": { + "node_modules/@testing-library/dom/node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3549,12 +3546,11 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/@testing-library/cypress/node_modules/pretty-format/node_modules/ansi-styles": { + "node_modules/@testing-library/dom/node_modules/pretty-format/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" }, @@ -3562,12 +3558,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@testing-library/cypress/node_modules/supports-color": { + "node_modules/@testing-library/dom/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3575,26 +3570,29 @@ "node": ">=8" } }, - "node_modules/@testing-library/dom": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", - "integrity": "sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==", + "node_modules/@testing-library/jest-dom": { + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" }, "engines": { - "node": ">=14" + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" } }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", @@ -3609,32 +3607,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=8" } }, - "node_modules/@testing-library/dom/node_modules/color-convert": { + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -3646,42 +3632,13 @@ "node": ">=7.0.0" } }, - "node_modules/@testing-library/dom/node_modules/color-name": { + "node_modules/@testing-library/jest-dom/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/@testing-library/dom/node_modules/deep-equal": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.2.tgz", - "integrity": "sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.1", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@testing-library/dom/node_modules/has-flag": { + "node_modules/@testing-library/jest-dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", @@ -3690,71 +3647,62 @@ "node": ">=8" } }, - "node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "node_modules/@testing-library/jest-dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" + "has-flag": "^4.0.0" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format/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, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=8" } }, - "node_modules/@testing-library/dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" }, "engines": { - "node": ">=8" + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" } }, - "node_modules/@testing-library/jest-dom": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", - "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "node_modules/@testing-library/react/node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", "dev": true, + "license": "MIT", "dependencies": { - "@adobe/css-tools": "^4.0.1", - "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", - "redent": "^3.0.0" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" }, "engines": { - "node": ">=8", - "npm": ">=6", - "yarn": ">=1" + "node": ">=14" } }, - "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "node_modules/@testing-library/react/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3765,24 +3713,39 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "node_modules/@testing-library/react/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/react/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "node_modules/@testing-library/react/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3790,49 +3753,62 @@ "node": ">=7.0.0" } }, - "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "node_modules/@testing-library/react/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, - "node_modules/@testing-library/jest-dom/node_modules/has-flag": { + "node_modules/@testing-library/react/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@testing-library/jest-dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@testing-library/react/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, "engines": { - "node": ">=8" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/@testing-library/react": { - "version": "14.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "node_modules/@testing-library/react/node_modules/pretty-format/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/@testing-library/react/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "node": ">=8" } }, "node_modules/@testing-library/user-event": { @@ -5990,6 +5966,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6840,6 +6845,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/core-js": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", + "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.39.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", @@ -7869,6 +7886,39 @@ } } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -8226,6 +8276,20 @@ "dotenv": "^8.2.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -8437,12 +8501,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -8460,6 +8522,7 @@ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -10343,15 +10406,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -10525,11 +10594,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10591,9 +10661,10 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -11214,13 +11285,14 @@ } }, "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -11245,13 +11317,14 @@ } }, "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -14471,6 +14544,15 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -15873,13 +15955,14 @@ } }, "node_modules/object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -18453,14 +18536,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -18817,12 +18955,14 @@ } }, "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, + "license": "MIT", "dependencies": { - "internal-slot": "^1.0.4" + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" }, "engines": { "node": ">= 0.4" diff --git a/workspaces/frontend/package.json b/workspaces/frontend/package.json index 12f5ff7b..0625d252 100644 --- a/workspaces/frontend/package.json +++ b/workspaces/frontend/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@cypress/code-coverage": "^3.13.5", "@testing-library/cypress": "^10.0.1", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "14.4.3", @@ -47,6 +48,7 @@ "chai-subset": "^1.6.0", "concurrently": "^9.1.0", "copy-webpack-plugin": "^11.0.0", + "core-js": "^3.39.0", "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", "cypress": "^13.15.0", @@ -56,6 +58,7 @@ "cypress-multi-reporters": "^2.0.4", "dotenv": "^16.4.5", "dotenv-webpack": "^8.1.0", + "expect": "^29.7.0", "html-webpack-plugin": "^5.6.0", "imagemin": "^8.0.1", "jest": "^29.7.0", diff --git a/workspaces/frontend/src/__mocks__/mockNamespaces.ts b/workspaces/frontend/src/__mocks__/mockNamespaces.ts new file mode 100644 index 00000000..6b6761fb --- /dev/null +++ b/workspaces/frontend/src/__mocks__/mockNamespaces.ts @@ -0,0 +1,7 @@ +import { NamespacesList } from '~/app/types'; + +export const mockNamespaces: NamespacesList = [ + { name: 'default' }, + { name: 'kubeflow' }, + { name: 'custom-namespace' }, +]; diff --git a/workspaces/frontend/src/__mocks__/styleMock.js b/workspaces/frontend/src/__mocks__/styleMock.js new file mode 100644 index 00000000..f053ebf7 --- /dev/null +++ b/workspaces/frontend/src/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/workspaces/frontend/src/__mocks__/utils.ts b/workspaces/frontend/src/__mocks__/utils.ts new file mode 100644 index 00000000..f808fe7c --- /dev/null +++ b/workspaces/frontend/src/__mocks__/utils.ts @@ -0,0 +1,5 @@ +import { ResponseBody } from '~/app/types'; + +export const mockBFFResponse = (data: T): ResponseBody => ({ + data, +}); diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts index 769e6a57..cd8f91d1 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/NamespaceSelector.cy.ts @@ -1,15 +1,16 @@ +import { mockNamespaces } from '~/__mocks__/mockNamespaces'; +import { mockBFFResponse } from '~/__mocks__/utils'; + const namespaces = ['default', 'kubeflow', 'custom-namespace']; -const mockNamespaces = { - data: [{ name: 'default' }, { name: 'kubeflow' }, { name: 'custom-namespace' }], -}; describe('Namespace Selector Dropdown', () => { beforeEach(() => { - // Mock the namespaces and selected namespace + // Mock the namespaces API response cy.intercept('GET', '/api/v1/namespaces', { - body: mockNamespaces, - }); + body: mockBFFResponse(mockNamespaces), + }).as('getNamespaces'); cy.visit('/'); + cy.wait('@getNamespaces'); }); it('should open the namespace dropdown and select a namespace', () => { @@ -38,4 +39,19 @@ describe('Namespace Selector Dropdown', () => { cy.get('[data-testid="nav-link-/notebookSettings"]').click(); cy.get('[data-testid="namespace-toggle"]').should('contain', 'custom-namespace'); }); + + it('should filter namespaces based on search input', () => { + cy.get('[data-testid="namespace-toggle"]').click(); + cy.get('[data-testid="namespace-search-input"]').type('custom') + cy.get('[data-testid="namespace-search-input"] input').should('have.value', 'custom'); + cy.get('[data-testid="namespace-search-button"]').click(); + // Verify that only the matching namespace is displayed + namespaces.forEach((ns) => { + if (ns === 'custom-namespace') { + cy.get(`[data-testid="dropdown-item-${ns}"]`).should('exist').and('contain', ns); + } else { + cy.get(`[data-testid="dropdown-item-${ns}"]`).should('not.exist'); + } + }); + }); }); diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts index 80404940..b784a2e2 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/application.cy.ts @@ -1,7 +1,18 @@ import { pageNotfound } from '~/__tests__/cypress/cypress/pages/pageNotFound'; import { home } from '~/__tests__/cypress/cypress/pages/home'; +import { mockNamespaces } from '~/__mocks__/mockNamespaces'; +import { mockBFFResponse } from '~/__mocks__/utils'; describe('Application', () => { + beforeEach(() => { + // Mock the namespaces API response + cy.intercept('GET', '/api/v1/namespaces', { + body: mockBFFResponse(mockNamespaces), + }).as('getNamespaces'); + cy.visit('/'); + cy.wait('@getNamespaces'); + }); + it('Page not found should render', () => { pageNotfound.visit(); }); diff --git a/workspaces/frontend/src/__tests__/unit/jest.d.ts b/workspaces/frontend/src/__tests__/unit/jest.d.ts new file mode 100644 index 00000000..5decfa3c --- /dev/null +++ b/workspaces/frontend/src/__tests__/unit/jest.d.ts @@ -0,0 +1,29 @@ +declare namespace jest { + interface Expect { + isIdentityEqual: (expected: T) => T; + } + + interface Matchers { + hookToBe: (expected: unknown) => R; + hookToStrictEqual: (expected: unknown) => R; + hookToHaveUpdateCount: (expected: number) => R; + hookToBeStable: < + V extends T extends Pick< + import('~/__tests__/unit/testUtils/hooks').RenderHookResultExt< + infer Result, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >, + 'result' + > + ? import('~/__tests__/unit/testUtils/hooks').BooleanValues + : never, + >( + expected?: V, + ) => R; + } + + interface Expect { + isIdentityEqual: (expected: unknown) => AsymmetricMatcher; + } +} diff --git a/workspaces/frontend/src/__tests__/unit/jest.setup.ts b/workspaces/frontend/src/__tests__/unit/jest.setup.ts new file mode 100644 index 00000000..05995b97 --- /dev/null +++ b/workspaces/frontend/src/__tests__/unit/jest.setup.ts @@ -0,0 +1,66 @@ +import { TextEncoder } from 'util'; +import { JestAssertionError } from 'expect'; +import 'core-js/actual/array/to-sorted'; +import { + BooleanValues, + RenderHookResultExt, + createComparativeValue, +} from '~/__tests__/unit/testUtils/hooks'; + +global.TextEncoder = TextEncoder; + +const tryExpect = (expectFn: () => void) => { + try { + expectFn(); + } catch (e) { + const { matcherResult } = e as JestAssertionError; + if (matcherResult) { + return { ...matcherResult, message: () => matcherResult.message }; + } + throw e; + } + return { + pass: true, + message: () => '', + }; +}; + +expect.extend({ + // custom asymmetric matchers + + /** + * Checks that a value is what you expect. + * It uses Object.is to check strict equality. + * + * Usage: + * expect.isIdentifyEqual(...) + */ + isIdentityEqual: (actual, expected) => ({ + pass: Object.is(actual, expected), + message: () => `expected ${actual} to be identity equal to ${expected}`, + }), + + // hook related custom matchers + hookToBe: (actual: RenderHookResultExt, expected) => + tryExpect(() => expect(actual.result.current).toBe(expected)), + + hookToStrictEqual: (actual: RenderHookResultExt, expected) => + tryExpect(() => expect(actual.result.current).toStrictEqual(expected)), + + hookToHaveUpdateCount: (actual: RenderHookResultExt, expected: number) => + tryExpect(() => expect(actual.getUpdateCount()).toBe(expected)), + + hookToBeStable: (actual: RenderHookResultExt, expected?: BooleanValues) => { + if (actual.getUpdateCount() <= 1) { + throw new Error('Cannot assert stability as the hook has not run at least 2 times.'); + } + if (typeof expected === 'undefined') { + return tryExpect(() => expect(actual.result.current).toBe(actual.getPreviousResult())); + } + return tryExpect(() => + expect(actual.result.current).toStrictEqual( + createComparativeValue(actual.getPreviousResult(), expected), + ), + ); + }, +}); diff --git a/workspaces/frontend/src/__tests__/unit/testUtils/hooks.spec.ts b/workspaces/frontend/src/__tests__/unit/testUtils/hooks.spec.ts new file mode 100644 index 00000000..d2509314 --- /dev/null +++ b/workspaces/frontend/src/__tests__/unit/testUtils/hooks.spec.ts @@ -0,0 +1,265 @@ +import * as React from 'react'; +import { createComparativeValue, renderHook, standardUseFetchState, testHook } from './hooks'; + +const useSayHello = (who: string, showCount = false) => { + const countRef = React.useRef(0); + countRef.current++; + return `Hello ${who}!${showCount && countRef.current > 1 ? ` x${countRef.current}` : ''}`; +}; + +const useSayHelloDelayed = (who: string, delay = 0) => { + const [speech, setSpeech] = React.useState(''); + React.useEffect(() => { + const handle = setTimeout(() => setSpeech(`Hello ${who}!`), delay); + return () => clearTimeout(handle); + }, [who, delay]); + return speech; +}; + +describe('hook test utils', () => { + it('simple testHook', () => { + const renderResult = testHook((who: string) => `Hello ${who}!`)('world'); + expect(renderResult).hookToBe('Hello world!'); + expect(renderResult).hookToHaveUpdateCount(1); + renderResult.rerender('world'); + expect(renderResult).hookToBe('Hello world!'); + expect(renderResult).hookToBeStable(); + expect(renderResult).hookToHaveUpdateCount(2); + }); + + it('use testHook for rendering', () => { + const renderResult = testHook(useSayHello)('world'); + expect(renderResult).hookToHaveUpdateCount(1); + expect(renderResult).hookToBe('Hello world!'); + expect(renderResult).hookToStrictEqual('Hello world!'); + + renderResult.rerender('world', false); + + expect(renderResult).hookToHaveUpdateCount(2); + expect(renderResult).hookToBe('Hello world!'); + expect(renderResult).hookToStrictEqual('Hello world!'); + expect(renderResult).hookToBeStable(); + + renderResult.rerender('world', true); + + expect(renderResult).hookToHaveUpdateCount(3); + expect(renderResult).hookToBe('Hello world! x3'); + expect(renderResult).hookToStrictEqual('Hello world! x3'); + }); + + it('use renderHook for rendering', () => { + type Props = { + who: string; + showCount?: boolean; + }; + const renderResult = renderHook(({ who, showCount }: Props) => useSayHello(who, showCount), { + initialProps: { + who: 'world', + }, + }); + + expect(renderResult).hookToHaveUpdateCount(1); + expect(renderResult).hookToBe('Hello world!'); + expect(renderResult).hookToStrictEqual('Hello world!'); + + renderResult.rerender({ + who: 'world', + }); + + expect(renderResult).hookToHaveUpdateCount(2); + expect(renderResult).hookToBe('Hello world!'); + expect(renderResult).hookToStrictEqual('Hello world!'); + + renderResult.rerender({ who: 'world' }); + + expect(renderResult).hookToHaveUpdateCount(3); + expect(renderResult).hookToBe('Hello world!'); + expect(renderResult).hookToStrictEqual('Hello world!'); + }); + + it('should use waitForNextUpdate for async update testing', async () => { + const renderResult = testHook(useSayHelloDelayed)('world'); + expect(renderResult).hookToHaveUpdateCount(1); + expect(renderResult).hookToBe(''); + + await renderResult.waitForNextUpdate(); + expect(renderResult).hookToHaveUpdateCount(2); + expect(renderResult).hookToBe('Hello world!'); + }); + + it('should throw error if waitForNextUpdate times out', async () => { + const renderResult = renderHook(() => useSayHelloDelayed('', 20)); + + await expect(renderResult.waitForNextUpdate({ timeout: 10, interval: 5 })).rejects.toThrow(); + expect(renderResult).hookToHaveUpdateCount(1); + + // unmount to test waiting for an update that will never happen + renderResult.unmount(); + + await expect(renderResult.waitForNextUpdate({ timeout: 500, interval: 10 })).rejects.toThrow(); + + expect(renderResult).hookToHaveUpdateCount(1); + }); + + it('should not throw if waitForNextUpdate timeout is sufficient', async () => { + const renderResult = renderHook(() => useSayHelloDelayed('', 20)); + + await expect( + renderResult.waitForNextUpdate({ timeout: 500, interval: 10 }), + ).resolves.not.toThrow(); + + expect(renderResult).hookToHaveUpdateCount(2); + }); + + it('should assert stability of results using isStable', () => { + let testValue = 'test'; + const renderResult = renderHook(() => testValue); + expect(renderResult).hookToHaveUpdateCount(1); + + renderResult.rerender(); + expect(renderResult).hookToHaveUpdateCount(2); + expect(renderResult).hookToBeStable(); + + testValue = 'new'; + renderResult.rerender(); + expect(renderResult).hookToHaveUpdateCount(3); + + renderResult.rerender(); + expect(renderResult).hookToHaveUpdateCount(4); + expect(renderResult).hookToBeStable(); + }); + + it(`should assert stability of result using isStable 'array'`, () => { + let testValue = ['test']; + // explicitly returns a new array each render to show the difference between `isStable` and `isStableArray` + const renderResult = renderHook(() => testValue); + expect(renderResult).hookToHaveUpdateCount(1); + + renderResult.rerender(); + expect(renderResult).hookToHaveUpdateCount(2); + expect(renderResult).hookToBeStable(); + expect(renderResult).hookToBeStable([true]); + + testValue = ['new']; + renderResult.rerender(); + expect(renderResult).hookToHaveUpdateCount(3); + expect(renderResult).hookToBeStable([false]); + + renderResult.rerender(); + expect(renderResult).hookToHaveUpdateCount(4); + expect(renderResult).hookToBeStable(); + expect(renderResult).hookToBeStable([true]); + }); + + it('standardUseFetchState should return an array matching the state of useFetchState', () => { + expect(['test', false, undefined, () => null]).toStrictEqual(standardUseFetchState('test')); + expect(['test', true, undefined, () => null]).toStrictEqual( + standardUseFetchState('test', true), + ); + expect(['test', false, new Error('error'), () => null]).toStrictEqual( + standardUseFetchState('test', false, new Error('error')), + ); + }); + + describe('createComparativeValue', () => { + it('should extract array values according to the boolean object', () => { + expect([1, 2, 3]).toStrictEqual(createComparativeValue([1, 2, 3], [true, true, true])); + expect([1, 2, 3]).toStrictEqual(createComparativeValue([1, 2, 3], [true, true, false])); + expect([1, 2, 3]).toStrictEqual(createComparativeValue([1, 2, 4], [true, true, false])); + expect([1, 2, 3]).toStrictEqual(createComparativeValue([1, 2, 4], [true, true])); + expect([1, 2, 3]).not.toStrictEqual(createComparativeValue([1, 4, 3], [true, true, true])); + expect([3, 2, 1]).not.toStrictEqual(createComparativeValue([1, 2, 3], [true, true, true])); + expect([true, false]).not.toStrictEqual(createComparativeValue([false, true], [true, true])); + // array comparison must have the same length, however the stability array may have a lesser length + expect([1, 2, 3, 4]).toStrictEqual(createComparativeValue([1, 2, 3, 5], [true, true, true])); + }); + + it('should extract object values according to the boolean object', () => { + expect({ a: 1, b: 2, c: 3 }).toStrictEqual( + createComparativeValue({ a: 1, b: 2, c: 3 }, { a: true, b: true, c: true }), + ); + expect({ a: 1, b: 2, c: 3 }).toStrictEqual( + createComparativeValue({ a: 1, b: 2, c: 3 }, { a: true, b: true, c: true }), + ); + expect({ a: 1, b: 2, c: 3 }).toStrictEqual( + createComparativeValue({ a: 1, b: 2, c: 4 }, { a: true, b: true, c: false }), + ); + expect({ a: 1, b: 2, c: 3 }).toStrictEqual( + createComparativeValue({ a: 1, b: 2, c: 4 }, { a: true, b: true }), + ); + expect({ a: 1, b: 2, c: 3 }).not.toStrictEqual( + createComparativeValue({ a: 1, b: 4, c: 3 }, { a: true, b: true, c: true }), + ); + }); + + it('should extract nested values', () => { + const testValue = { + a: 1, + b: { + c: 2, + d: [{ e: 3 }, 'f', {}], + }, + }; + expect(testValue).toStrictEqual( + createComparativeValue( + { a: 10, b: { c: 2, d: [null, 'f', null] } }, + { + b: { + c: true, + d: [false, true], + }, + }, + ), + ); + }); + + it('should extract objects for identity comparisons', () => { + const obj = {}; + const array: string[] = []; + const testValue = { + a: obj, + b: array, + c: { + d: obj, + e: array, + }, + }; + + expect(testValue).not.toStrictEqual( + createComparativeValue( + { + a: {}, + b: [], + c: { + d: {}, + e: [], + }, + }, + { + a: true, + b: true, + c: { d: true, e: true }, + }, + ), + ); + + expect(testValue).toStrictEqual( + createComparativeValue( + { + a: obj, + b: array, + c: { + d: obj, + e: array, + }, + }, + { + a: true, + b: true, + c: { d: true, e: true }, + }, + ), + ); + }); + }); +}); diff --git a/workspaces/frontend/src/__tests__/unit/testUtils/hooks.ts b/workspaces/frontend/src/__tests__/unit/testUtils/hooks.ts new file mode 100644 index 00000000..d9abd7e6 --- /dev/null +++ b/workspaces/frontend/src/__tests__/unit/testUtils/hooks.ts @@ -0,0 +1,204 @@ +import { + renderHook as renderHookRTL, + RenderHookOptions, + RenderHookResult, + waitFor, + waitForOptions, +} from '@testing-library/react'; +import { queries, Queries } from '@testing-library/dom'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type BooleanValues = T extends boolean | number | null | undefined | Function + ? boolean | undefined + : boolean | undefined | { [K in keyof T]?: BooleanValues }; + +/** + * Extension of RTL RenderHookResult providing functions used query the current state of the result. + */ +export type RenderHookResultExt = RenderHookResult & { + /** + * Returns the previous result. + */ + getPreviousResult: () => Result; + + /** + * Get the update count for how many times the hook has been rendered. + * An update occurs initially on render, subsequently when re-rendered, and also whenever the hook itself triggers a re-render. + * eg. An `useEffect` triggering a state update. + */ + getUpdateCount: () => number; + + /** + * Returns a Promise that resolves the next time the hook renders, commonly when state is updated as the result of an asynchronous update. + * + * Since `waitForNextUpdate` works using interval checks (backed by `waitFor`), it's possible that multiple updates may occur while waiting. + */ + waitForNextUpdate: (options?: Pick) => Promise; +}; + +/** + * Wrapper on top of RTL `renderHook` returning a result that implements the `RenderHookResultExt` interface. + * + * `renderHook` provides full control over the rendering of your hook including the ability to wrap the test component. + * This is usually used to add context providers from `React.createContext` for the hook to access with `useContext`. + * `initialProps` and props subsequently set by `rerender` will be provided to the wrapper. + * + * ``` + * const renderResult = renderHook(({ who }: { who: string }) => useSayHello(who), { initialProps: { who: 'world' }}); + * expect(renderResult).hookToBe('Hello world!'); + * renderResult.rerender({ who: 'there' }); + * expect(renderResult).hookToBe('Hello there!'); + * ``` + */ +export const renderHook = < + Result, + Props, + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +>( + render: (initialProps: Props) => Result, + options?: RenderHookOptions, +): RenderHookResultExt => { + let updateCount = 0; + let prevResult: Result; + let currentResult: Result; + + const renderResult = renderHookRTL((props) => { + updateCount++; + prevResult = currentResult; + currentResult = render(props); + return currentResult; + }, options); + + const renderResultExt: RenderHookResultExt = { + ...renderResult, + + getPreviousResult: () => (updateCount > 1 ? prevResult : renderResult.result.current), + + getUpdateCount: () => updateCount, + + waitForNextUpdate: async (currentOptions) => { + const expected = updateCount; + try { + await waitFor(() => expect(updateCount).toBeGreaterThan(expected), currentOptions); + } catch { + throw new Error('waitForNextUpdate timed out'); + } + }, + }; + + return renderResultExt; +}; + +/** + * Lightweight API for testing a single hook. + * + * Prefer this method of testing over `renderHook` for simplicity. + * + * ``` + * const renderResult = testHook(useSayHello)('world'); + * expectHook(renderResult).toBe('Hello world!'); + * renderResult.rerender('there'); + * expectHook(renderResult).toBe('Hello there!'); + * ``` + */ +export const testHook = + (hook: (...params: P) => Result) => + // not ideal to nest functions in terms of API but cannot find a better way to infer P from hook and not initialParams + ( + ...initialParams: P + ): Omit, 'rerender'> & { + rerender: (...params: typeof initialParams) => void; + } => { + const renderResult = renderHook( + ({ $params }) => hook(...$params), + { + initialProps: { + $params: initialParams, + }, + }, + ); + + return { + ...renderResult, + rerender: (...params) => renderResult.rerender({ $params: params }), + }; + }; + +/** + * A helper function for asserting the return value of hooks based on `useFetchState`. + * + * eg. + * ``` + * expectHook(renderResult).isStrictEqual(standardUseFetchState('test value', true)) + * ``` + * is equivalent to: + * ``` + * expectHook(renderResult).isStrictEqual(['test value', true, undefined, expect.any(Function)]) + * ``` + */ +export const standardUseFetchState = ( + data: D, + loaded = false, + error?: Error, +): [ + data: D, + loaded: boolean, + loadError: Error | undefined, + refresh: () => Promise, +] => [data, loaded, error, expect.any(Function)]; + +// create a new asymmetric matcher that matches everything +const everything = () => { + const r = expect.anything(); + r.asymmetricMatch = () => true; + return r; +}; + +/** + * Extracts a subset of values from the source that can be used to compare equality. + * + * Recursively traverses the `booleanTarget`. For every property or array index equal to `true`, + * adds the value of the source to the result wrapped in custom matcher `expect.isIdentityEqual`. + * If the entry is `false` or `undefined`, add an everything matcher to the result. + */ +export const createComparativeValue = (source: T, booleanTarget: BooleanValues): unknown => + createComparativeValueRecursive(source, booleanTarget); + +const createComparativeValueRecursive = ( + source: unknown, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + booleanTarget: boolean | string | number | Function | BooleanValues, +) => { + if (typeof booleanTarget === 'boolean') { + return booleanTarget ? expect.isIdentityEqual(source) : everything(); + } + if (Array.isArray(booleanTarget)) { + if (Array.isArray(source)) { + const r = new Array(source.length).fill(everything()); + booleanTarget.forEach((b, i) => { + if (b != null) { + r[i] = createComparativeValueRecursive(source[i], b); + } + }); + return r; + } + return undefined; + } + if ( + source == null || + typeof source === 'string' || + typeof source === 'number' || + typeof source === 'function' + ) { + return source; + } + const obj: { [k: string]: unknown } = {}; + const btObj = booleanTarget as { [k: string]: unknown }; + Object.keys(btObj).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + obj[key] = createComparativeValueRecursive((source as any)[key] as unknown, btObj[key] as any); + }); + return expect.objectContaining(obj); +}; diff --git a/workspaces/frontend/src/app/App.tsx b/workspaces/frontend/src/app/App.tsx index 955ee1ae..26747d81 100644 --- a/workspaces/frontend/src/app/App.tsx +++ b/workspaces/frontend/src/app/App.tsx @@ -15,6 +15,7 @@ import NamespaceSelector from '~/shared/components/NamespaceSelector'; import { NamespaceProvider } from './context/NamespaceContextProvider'; import AppRoutes from './AppRoutes'; import NavSidebar from './NavSidebar'; +import { NotebookContextProvider } from './context/NotebookContext'; const App: React.FC = () => { const masthead = ( @@ -37,16 +38,18 @@ const App: React.FC = () => { ); return ( - - } - > - - - + + + } + > + + + + ); }; diff --git a/workspaces/frontend/src/app/const.ts b/workspaces/frontend/src/app/const.ts new file mode 100644 index 00000000..35826e7d --- /dev/null +++ b/workspaces/frontend/src/app/const.ts @@ -0,0 +1 @@ +export const BFF_API_VERSION = 'v1'; diff --git a/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx b/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx index 16d6ba14..4a1f5ad6 100644 --- a/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx +++ b/workspaces/frontend/src/app/context/NamespaceContextProvider.tsx @@ -1,5 +1,6 @@ import React, { useState, useContext, ReactNode, useMemo, useCallback } from 'react'; import useMount from '~/app/hooks/useMount'; +import useNamespaces from '~/app/hooks/useNamespaces'; interface NamespaceContextState { namespaces: string[]; @@ -24,21 +25,29 @@ interface NamespaceProviderProps { export const NamespaceProvider: React.FC = ({ children }) => { const [namespaces, setNamespaces] = useState([]); const [selectedNamespace, setSelectedNamespace] = useState(''); + const [namespacesData, loaded, loadError] = useNamespaces(); + + const fetchNamespaces = useCallback(() => { + if (loaded && namespacesData) { + const namespaceNames = namespacesData.map((ns) => ns.name); + setNamespaces(namespaceNames); + setSelectedNamespace(namespaceNames.length > 0 ? namespaceNames[0] : ''); + } else { + if (loadError) { + console.error('Error loading namespaces: ', loadError); + } + setNamespaces([]); + setSelectedNamespace(''); + } + }, [loaded, namespacesData, loadError]); - // Todo: Need to replace with actual API call - const fetchNamespaces = useCallback(async () => { - const mockNamespaces = { - data: [{ name: 'default' }, { name: 'kubeflow' }, { name: 'custom-namespace' }], - }; - const namespaceNames = mockNamespaces.data.map((ns) => ns.name); - setNamespaces(namespaceNames); - setSelectedNamespace(namespaceNames.length > 0 ? namespaceNames[0] : ''); - }, []); useMount(fetchNamespaces); + const namespacesContextValues = useMemo( () => ({ namespaces, selectedNamespace, setSelectedNamespace }), [namespaces, selectedNamespace], ); + return ( {children} diff --git a/workspaces/frontend/src/app/context/NotebookContext.tsx b/workspaces/frontend/src/app/context/NotebookContext.tsx new file mode 100644 index 00000000..f187489f --- /dev/null +++ b/workspaces/frontend/src/app/context/NotebookContext.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { BFF_API_VERSION } from '~/app/const'; +import useNotebookAPIState, { NotebookAPIState } from './useNotebookAPIState'; + +export type NotebookContextType = { + apiState: NotebookAPIState; + refreshAPIState: () => void; +}; + +export const NotebookContext = React.createContext({ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + apiState: { apiAvailable: false, api: null as unknown as NotebookAPIState['api'] }, + refreshAPIState: () => undefined, +}); + +export const NotebookContextProvider: React.FC = ({ children }) => { + const hostPath = `/api/${BFF_API_VERSION}`; + + const [apiState, refreshAPIState] = useNotebookAPIState(hostPath); + + return ( + ({ + apiState, + refreshAPIState, + }), + [apiState, refreshAPIState], + )} + > + {children} + + ); +}; diff --git a/workspaces/frontend/src/app/context/useNotebookAPIState.tsx b/workspaces/frontend/src/app/context/useNotebookAPIState.tsx new file mode 100644 index 00000000..cb078d08 --- /dev/null +++ b/workspaces/frontend/src/app/context/useNotebookAPIState.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { APIState } from '~/shared/api/types'; +import { NotebookAPIs } from '~/app/types'; +import { getNamespaces } from '~/shared/api/notebookService'; +import useAPIState from '~/shared/api/useAPIState'; + +export type NotebookAPIState = APIState; + +const useNotebookAPIState = ( + hostPath: string | null, +): [apiState: NotebookAPIState, refreshAPIState: () => void] => { + const createAPI = React.useCallback( + (path: string) => ({ + getNamespaces: getNamespaces(path), + }), + [], + ); + + return useAPIState(hostPath, createAPI); +}; + +export default useNotebookAPIState; diff --git a/workspaces/frontend/src/app/hooks/useNamespaces.ts b/workspaces/frontend/src/app/hooks/useNamespaces.ts new file mode 100644 index 00000000..8b84fd33 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useNamespaces.ts @@ -0,0 +1,26 @@ +import * as React from 'react'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, +} from '~/shared/utilities/useFetchState'; +import { NamespacesList } from '~/app/types'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; + +const useNamespaces = (): FetchState => { + const { api, apiAvailable } = useNotebookAPI(); + + const call = React.useCallback>( + (opts) => { + if (!apiAvailable) { + return Promise.reject(new Error('API not yet available')); + } + + return api.getNamespaces(opts); + }, + [api, apiAvailable], + ); + + return useFetchState(call, null); +}; + +export default useNamespaces; diff --git a/workspaces/frontend/src/app/hooks/useNotebookAPI.ts b/workspaces/frontend/src/app/hooks/useNotebookAPI.ts new file mode 100644 index 00000000..468ed669 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useNotebookAPI.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { NotebookAPIState } from '~/app/context/useNotebookAPIState'; +import { NotebookContext } from '~/app/context/NotebookContext'; + +type UseNotebookAPI = NotebookAPIState & { + refreshAllAPI: () => void; +}; + +export const useNotebookAPI = (): UseNotebookAPI => { + const { apiState, refreshAPIState: refreshAllAPI } = React.useContext(NotebookContext); + + return { + refreshAllAPI, + ...apiState, + }; +}; diff --git a/workspaces/frontend/src/app/types.ts b/workspaces/frontend/src/app/types.ts new file mode 100644 index 00000000..ca80e9cd --- /dev/null +++ b/workspaces/frontend/src/app/types.ts @@ -0,0 +1,69 @@ +import { APIOptions } from '~/shared/api/types'; + +export type ResponseBody = { + data: T; + metadata?: Record; +}; + +export enum ResponseMetadataType { + INT = 'MetadataIntValue', + DOUBLE = 'MetadataDoubleValue', + STRING = 'MetadataStringValue', + STRUCT = 'MetadataStructValue', + PROTO = 'MetadataProtoValue', + BOOL = 'MetadataBoolValue', +} + +export type ResponseCustomPropertyInt = { + metadataType: ResponseMetadataType.INT; + int_value: string; // int64-formatted string +}; + +export type ResponseCustomPropertyDouble = { + metadataType: ResponseMetadataType.DOUBLE; + double_value: number; +}; + +export type ResponseCustomPropertyString = { + metadataType: ResponseMetadataType.STRING; + string_value: string; +}; + +export type ResponseCustomPropertyStruct = { + metadataType: ResponseMetadataType.STRUCT; + struct_value: string; // Base64 encoded bytes for struct value +}; + +export type ResponseCustomPropertyProto = { + metadataType: ResponseMetadataType.PROTO; + type: string; // url describing proto value + proto_value: string; // Base64 encoded bytes for proto value +}; + +export type ResponseCustomPropertyBool = { + metadataType: ResponseMetadataType.BOOL; + bool_value: boolean; +}; + +export type ResponseCustomProperty = + | ResponseCustomPropertyInt + | ResponseCustomPropertyDouble + | ResponseCustomPropertyString + | ResponseCustomPropertyStruct + | ResponseCustomPropertyProto + | ResponseCustomPropertyBool; + +export type ResponseCustomProperties = Record; +export type ResponseStringCustomProperties = Record; + +export type Namespace = { + name: string; +}; + +export type NamespacesList = Namespace[]; + +export type GetNamespaces = (opts: APIOptions) => Promise; + +export type NotebookAPIs = { + getNamespaces: GetNamespaces; +}; diff --git a/workspaces/frontend/src/shared/api/__tests__/errorUtils.spec.ts b/workspaces/frontend/src/shared/api/__tests__/errorUtils.spec.ts new file mode 100644 index 00000000..16870890 --- /dev/null +++ b/workspaces/frontend/src/shared/api/__tests__/errorUtils.spec.ts @@ -0,0 +1,35 @@ +import { NotReadyError } from '~/shared/utilities/useFetchState'; +import { APIError } from '~/shared/api/types'; +import { handleRestFailures } from '~/shared/api/errorUtils'; +import { mockNamespaces } from '~/__mocks__/mockNamespaces'; +import { mockBFFResponse } from '~/__mocks__/utils'; + +describe('handleRestFailures', () => { + it('should successfully return namespaces', async () => { + const result = await handleRestFailures(Promise.resolve(mockBFFResponse(mockNamespaces))); + expect(result.data).toStrictEqual(mockNamespaces); + }); + + it('should handle and throw notebook errors', async () => { + const statusMock: APIError = { + error: { + code: '', + message: 'error', + }, + }; + + await expect(handleRestFailures(Promise.resolve(statusMock))).rejects.toThrow('error'); + }); + + it('should handle common state errors ', async () => { + await expect(handleRestFailures(Promise.reject(new NotReadyError('error')))).rejects.toThrow( + 'error', + ); + }); + + it('should handle other errors', async () => { + await expect(handleRestFailures(Promise.reject(new Error('error')))).rejects.toThrow( + 'Error communicating with server', + ); + }); +}); diff --git a/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts b/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts new file mode 100644 index 00000000..50f12a9e --- /dev/null +++ b/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts @@ -0,0 +1,38 @@ +import { restGET } from '~/shared/api/apiUtils'; +import { handleRestFailures } from '~/shared/api/errorUtils'; +import { getNamespaces } from '~/shared/api/notebookService'; +import { BFF_API_VERSION } from '~/app/const'; + +const mockRestPromise = Promise.resolve({ data: {} }); +const mockRestResponse = {}; + +jest.mock('~/shared/api/apiUtils', () => ({ + restCREATE: jest.fn(() => mockRestPromise), + restGET: jest.fn(() => mockRestPromise), + restPATCH: jest.fn(() => mockRestPromise), + isNotebookResponse: jest.fn(() => true), +})); + +jest.mock('~/shared/api/errorUtils', () => ({ + handleRestFailures: jest.fn(() => mockRestPromise), +})); + +const handleRestFailuresMock = jest.mocked(handleRestFailures); +const restGETMock = jest.mocked(restGET); +const APIOptionsMock = {}; + +describe('getNamespaces', () => { + it('should call restGET and handleRestFailures to fetch namespaces', async () => { + const response = await getNamespaces(`/api/${BFF_API_VERSION}/namespaces`)(APIOptionsMock); + expect(response).toEqual(mockRestResponse); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + `/api/${BFF_API_VERSION}/namespaces`, + `/namespaces`, + {}, + APIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); + }); +}); diff --git a/workspaces/frontend/src/shared/api/apiUtils.ts b/workspaces/frontend/src/shared/api/apiUtils.ts new file mode 100644 index 00000000..c5105826 --- /dev/null +++ b/workspaces/frontend/src/shared/api/apiUtils.ts @@ -0,0 +1,182 @@ +import { APIOptions } from '~/shared/api/types'; +import { EitherOrNone } from '~/shared/typeHelpers'; +import { ResponseBody } from '~/app/types'; +import { DEV_MODE, AUTH_HEADER } from '~/shared/utilities/const'; + +export const mergeRequestInit = ( + opts: APIOptions = {}, + specificOpts: RequestInit = {}, +): RequestInit => ({ + ...specificOpts, + ...(opts.signal && { signal: opts.signal }), + headers: { + ...(opts.headers ?? {}), + ...(specificOpts.headers ?? {}), + }, +}); + +type CallRestJSONOptions = { + queryParams?: Record; + parseJSON?: boolean; +} & EitherOrNone< + { + fileContents: string; + }, + { + data: Record; + } +>; + +const callRestJSON = ( + host: string, + path: string, + requestInit: RequestInit, + { data, fileContents, queryParams, parseJSON = true }: CallRestJSONOptions, +): Promise => { + const { method, ...otherOptions } = requestInit; + + const sanitizedQueryParams = queryParams + ? Object.entries(queryParams).reduce((acc, [key, value]) => { + if (value) { + return { ...acc, [key]: value }; + } + + return acc; + }, {}) + : null; + + const searchParams = sanitizedQueryParams + ? new URLSearchParams(sanitizedQueryParams).toString() + : null; + + let requestData: string | undefined; + let contentType: string | undefined; + let formData: FormData | undefined; + if (fileContents) { + formData = new FormData(); + formData.append( + 'uploadfile', + new Blob([fileContents], { type: 'application/x-yaml' }), + 'uploadedFile.yml', + ); + } else if (data) { + // It's OK for contentType and requestData to BOTH be undefined for e.g. a GET request or POST with no body. + contentType = 'application/json;charset=UTF-8'; + requestData = JSON.stringify(data); + } + + return fetch(`${host}${path}${searchParams ? `?${searchParams}` : ''}`, { + ...otherOptions, + headers: { + ...otherOptions.headers, + ...(DEV_MODE && { [AUTH_HEADER]: localStorage.getItem(AUTH_HEADER) }), + ...(contentType && { 'Content-Type': contentType }), + }, + method, + body: formData ?? requestData, + }).then((response) => + response.text().then((fetchedData) => { + if (parseJSON) { + return JSON.parse(fetchedData); + } + return fetchedData; + }), + ); +}; + +export const restGET = ( + host: string, + path: string, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'GET' }), { + queryParams, + parseJSON: options?.parseJSON, + }); + +/** Standard POST */ +export const restCREATE = ( + host: string, + path: string, + data: Record, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'POST' }), { + data, + queryParams, + parseJSON: options?.parseJSON, + }); + +/** POST -- but with file content instead of body data */ +export const restFILE = ( + host: string, + path: string, + fileContents: string, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'POST' }), { + fileContents, + queryParams, + parseJSON: options?.parseJSON, + }); + +/** POST -- but no body data -- targets simple endpoints */ +export const restENDPOINT = ( + host: string, + path: string, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'POST' }), { + queryParams, + parseJSON: options?.parseJSON, + }); + +export const restUPDATE = ( + host: string, + path: string, + data: Record, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'PUT' }), { + data, + queryParams, + parseJSON: options?.parseJSON, + }); + +export const restPATCH = ( + host: string, + path: string, + data: Record, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'PATCH' }), { + data, + parseJSON: options?.parseJSON, + }); + +export const restDELETE = ( + host: string, + path: string, + data: Record, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'DELETE' }), { + data, + queryParams, + parseJSON: options?.parseJSON, + }); + +export const isNotebookResponse = (response: unknown): response is ResponseBody => { + if (typeof response === 'object' && response !== null) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const notebookBody = response as { data?: T }; + return notebookBody.data !== undefined; + } + return false; +}; diff --git a/workspaces/frontend/src/shared/api/errorUtils.ts b/workspaces/frontend/src/shared/api/errorUtils.ts new file mode 100644 index 00000000..47046554 --- /dev/null +++ b/workspaces/frontend/src/shared/api/errorUtils.ts @@ -0,0 +1,25 @@ +import { APIError } from '~/shared/api/types'; +import { isCommonStateError } from '~/shared/utilities/useFetchState'; + +const isError = (e: unknown): e is APIError => typeof e === 'object' && e !== null && 'error' in e; + +export const handleRestFailures = (promise: Promise): Promise => + promise + .then((result) => { + if (isError(result)) { + throw result; + } + return result; + }) + .catch((e) => { + if (isError(e)) { + throw new Error(e.error.message); + } + if (isCommonStateError(e)) { + // Common state errors are handled by useFetchState at storage level, let them deal with it + throw e; + } + // eslint-disable-next-line no-console + console.error('Unknown API error', e); + throw new Error('Error communicating with server'); + }); diff --git a/workspaces/frontend/src/shared/api/notebookService.ts b/workspaces/frontend/src/shared/api/notebookService.ts new file mode 100644 index 00000000..5f38a5cc --- /dev/null +++ b/workspaces/frontend/src/shared/api/notebookService.ts @@ -0,0 +1,14 @@ +import { NamespacesList } from '~/app/types'; +import { isNotebookResponse, restGET } from '~/shared/api/apiUtils'; +import { APIOptions } from '~/shared/api/types'; +import { handleRestFailures } from '~/shared/api/errorUtils'; + +export const getNamespaces = + (hostPath: string) => + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/namespaces`, {}, opts)).then((response) => { + if (isNotebookResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); diff --git a/workspaces/frontend/src/shared/api/types.ts b/workspaces/frontend/src/shared/api/types.ts new file mode 100644 index 00000000..bb0055bc --- /dev/null +++ b/workspaces/frontend/src/shared/api/types.ts @@ -0,0 +1,20 @@ +export type APIOptions = { + dryRun?: boolean; + signal?: AbortSignal; + parseJSON?: boolean; + headers?: Record; +}; + +export type APIError = { + error: { + code: string; + message: string; + }; +}; + +export type APIState = { + /** If API will successfully call */ + apiAvailable: boolean; + /** The available API functions */ + api: T; +}; diff --git a/workspaces/frontend/src/shared/api/useAPIState.ts b/workspaces/frontend/src/shared/api/useAPIState.ts new file mode 100644 index 00000000..e6c7ec87 --- /dev/null +++ b/workspaces/frontend/src/shared/api/useAPIState.ts @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { APIState } from '~/shared/api/types'; + +const useAPIState = ( + hostPath: string | null, + createAPI: (path: string) => T, +): [apiState: APIState, refreshAPIState: () => void] => { + const [internalAPIToggleState, setInternalAPIToggleState] = React.useState(false); + + const refreshAPIState = React.useCallback(() => { + setInternalAPIToggleState((v) => !v); + }, []); + + const apiState = React.useMemo>(() => { + let path = hostPath; + if (!path) { + // TODO: we need to figure out maybe a stopgap or something + path = ''; + } + const api = createAPI(path); + + return { + apiAvailable: !!path, + api, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createAPI, hostPath, internalAPIToggleState]); + + return [apiState, refreshAPIState]; +}; + +export default useAPIState; diff --git a/workspaces/frontend/src/shared/components/NamespaceSelector.tsx b/workspaces/frontend/src/shared/components/NamespaceSelector.tsx index 1c510aa0..ee4ebd2d 100644 --- a/workspaces/frontend/src/shared/components/NamespaceSelector.tsx +++ b/workspaces/frontend/src/shared/components/NamespaceSelector.tsx @@ -19,7 +19,7 @@ import { useNamespaceContext } from '~/app/context/NamespaceContextProvider'; const NamespaceSelector: FC = () => { const { namespaces, selectedNamespace, setSelectedNamespace } = useNamespaceContext(); - const [isOpen, setIsOpen] = useState(false); + const [isNamespaceDropdownOpen, setIsNamespaceDropdownOpen] = useState(false); const [searchInputValue, setSearchInputValue] = useState(''); const [filteredNamespaces, setFilteredNamespaces] = useState(namespaces); @@ -28,10 +28,10 @@ const NamespaceSelector: FC = () => { }, [namespaces]); const onToggleClick = () => { - if (!isOpen) { + if (!isNamespaceDropdownOpen) { onClearSearch(); } - setIsOpen(!isOpen); + setIsNamespaceDropdownOpen(!isNamespaceDropdownOpen); }; const onSearchInputChange = (value: string) => { @@ -54,10 +54,12 @@ const NamespaceSelector: FC = () => { const onSelect: DropdownProps['onSelect'] = (_event, value) => { setSelectedNamespace(value as string); - setIsOpen(false); + setIsNamespaceDropdownOpen(false); }; - const onClearSearch = () => { + const onClearSearch = (event?: React.MouseEvent | React.ChangeEvent | React.FormEvent) => { + // Prevent the event from bubbling up and triggering dropdown close + event?.stopPropagation(); setSearchInputValue(''); setFilteredNamespaces(namespaces); }; @@ -84,14 +86,16 @@ const NamespaceSelector: FC = () => { {selectedNamespace} )} - isOpen={isOpen} + isOpen={isNamespaceDropdownOpen} + onOpenChange={(isOpen) => setIsNamespaceDropdownOpen(isOpen)} + onOpenChangeKeys={['Escape']} isScrollable data-testid="namespace-dropdown" > @@ -104,9 +108,9 @@ const NamespaceSelector: FC = () => { placeholder="Search Namespace" onChange={(_event, value) => onSearchInputChange(value)} onKeyDown={onEnterPressed} - onClear={onClearSearch} - // resetButtonLabel="Clear search" + onClear={(event) => onClearSearch(event)} aria-labelledby="namespace-search-button" + data-testid="namespace-search-input" /> @@ -116,6 +120,7 @@ const NamespaceSelector: FC = () => { id="namespace-search-button" onClick={onSearchButtonClick} icon={ diff --git a/workspaces/frontend/src/shared/typeHelpers.ts b/workspaces/frontend/src/shared/typeHelpers.ts new file mode 100644 index 00000000..a590b848 --- /dev/null +++ b/workspaces/frontend/src/shared/typeHelpers.ts @@ -0,0 +1,160 @@ +/** + * The type `{}` doesn't mean "any empty object", it means "any non-nullish value". + * + * Use the `AnyObject` type for objects whose structure is unknown. + * + * @see https://github.com/typescript-eslint/typescript-eslint/issues/2063#issuecomment-675156492 + */ +export type AnyObject = Record; + +/** + * Takes a type and makes all properties partial within it. + * + * TODO: Implement the SDK & Patch logic -- this should stop being needed as things will be defined as Patches + */ +export type RecursivePartial = T extends object + ? { + [P in keyof T]?: RecursivePartial; + } + : T; + +/** + * Partial only some properties. + * + * eg. PartialSome + */ +export type PartialSome = Pick, Keys> & + Omit; + +/** + * Unions all values of an object togethers -- antithesis to `keyof myObj`. + */ +export type ValueOf = T[keyof T]; + +/** + * Never allow any properties of `Type`. + * + * Utility type, probably never a reason to export. + */ +type Never = { + [K in keyof Type]?: never; +}; + +/** + * Either TypeA properties or TypeB properties -- never both. + * + * @example + * ```ts + * type MyType = EitherNotBoth<{ foo: boolean }, { bar: boolean }>; + * + * // Valid usages: + * const objA: MyType = { + * foo: true, + * }; + * const objB: MyType = { + * bar: true, + * }; + * + * // TS Error -- can't have both properties: + * const objBoth: MyType = { + * foo: true, + * bar: true, + * }; + * + * // TS Error -- must have at least one property: + * const objNeither: MyType = { + * }; + * ``` + */ +export type EitherNotBoth = (TypeA & Never) | (TypeB & Never); + +/** + * Either TypeA properties or TypeB properties or neither of the properties -- never both. + * + * @example + * ```ts + * type MyType = EitherOrBoth<{ foo: boolean }, { bar: boolean }>; + * + * // Valid usages: + * const objA: MyType = { + * foo: true, + * }; + * const objB: MyType = { + * bar: true, + * }; + * const objBoth: MyType = { + * foo: true, + * bar: true, + * }; + * + * // TS Error -- can't omit both properties: + * const objNeither: MyType = { + * }; + * ``` + */ +export type EitherOrBoth = EitherNotBoth | (TypeA & TypeB); + +/** + * Either TypeA properties or TypeB properties or neither of the properties -- never both. + * + * @example + * ```ts + * type MyType = EitherOrNone<{ foo: boolean }, { bar: boolean }>; + * + * // Valid usages: + * const objA: MyType = { + * foo: true, + * }; + * const objB: MyType = { + * bar: true, + * }; + * const objNeither: MyType = { + * }; + * + * // TS Error -- can't have both properties: + * const objBoth: MyType = { + * foo: true, + * bar: true, + * }; + * ``` + */ +export type EitherOrNone = + | EitherNotBoth + | (Never & Never); + +// support types for `ExactlyOne` +type Explode = keyof T extends infer K + ? K extends unknown + ? { [I in keyof T]: I extends K ? T[I] : never } + : never + : never; +type AtMostOne = Explode>; +type AtLeastOne }> = Partial & U[keyof U]; + +/** + * Create a type where exactly one of multiple properties must be supplied. + * + * @example + * ```ts + * type Foo = ExactlyOne<{ a: number, b: string, c: boolean}>; + * + * // Valid usages: + * const objA: Foo = { + * a: 1, + * }; + * const objB: Foo = { + * b: 'hi', + * }; + * const objC: Foo = { + * c: true, + * }; + * + * // TS Error -- can't have more than one property: + * const objAll: Foo = { + * a: 1, + * b: 'hi', + * c: true, + * }; + * ``` + */ +export type ExactlyOne = AtMostOne & AtLeastOne; diff --git a/workspaces/frontend/src/shared/utilities/const.ts b/workspaces/frontend/src/shared/utilities/const.ts new file mode 100644 index 00000000..6b3c5d9b --- /dev/null +++ b/workspaces/frontend/src/shared/utilities/const.ts @@ -0,0 +1,4 @@ +const DEV_MODE = process.env.APP_ENV === 'development'; +const AUTH_HEADER = process.env.AUTH_HEADER || 'kubeflow-userid'; + +export { DEV_MODE, AUTH_HEADER }; diff --git a/workspaces/frontend/src/shared/utilities/useFetchState.ts b/workspaces/frontend/src/shared/utilities/useFetchState.ts new file mode 100644 index 00000000..6f302b6c --- /dev/null +++ b/workspaces/frontend/src/shared/utilities/useFetchState.ts @@ -0,0 +1,258 @@ +import * as React from 'react'; +import { APIOptions } from '~/shared/api/types'; + +/** + * Allows "I'm not ready" rejections if you lack a lazy provided prop + * e.g. Promise.reject(new NotReadyError('Do not have namespace')) + */ +export class NotReadyError extends Error { + constructor(reason: string) { + super(`Not ready yet. ${reason}`); + this.name = 'NotReadyError'; + } +} + +/** + * Checks to see if it's a standard error handled by useStateFetch .catch block. + */ +export const isCommonStateError = (e: Error): boolean => { + if (e.name === 'NotReadyError') { + // An escape hatch for callers to reject the call at this fetchCallbackPromise reference + // Re-compute your callback to re-trigger again + return true; + } + if (e.name === 'AbortError') { + // Abort errors are silent + return true; + } + + return false; +}; + +/** Provided as a promise, so you can await a refresh before enabling buttons / closing modals. + * Returns the successful value or nothing if the call was cancelled / didn't complete. */ +export type FetchStateRefreshPromise = () => Promise; + +/** Return state */ +export type FetchState = [ + data: Type, + loaded: boolean, + loadError: Error | undefined, + /** This promise should never throw to the .catch */ + refresh: FetchStateRefreshPromise, +]; + +type SetStateLazy = (lastState: Type) => Type; +export type AdHocUpdate = (updateLater: (updater: SetStateLazy) => void) => void; + +const isAdHocUpdate = (r: Type | AdHocUpdate): r is AdHocUpdate => + typeof r === 'function'; + +/** + * All callbacks will receive a APIOptions, which includes a signal to provide to a RequestInit. + * This will allow the call to be cancelled if the hook needs to unload. It is recommended that you + * upgrade your API handlers to support this. + */ +type FetchStateCallbackPromiseReturn = (opts: APIOptions) => Return; + +/** + * Standard usage. Your callback should return a Promise that resolves to your data. + */ +export type FetchStateCallbackPromise = FetchStateCallbackPromiseReturn>; + +/** + * Advanced usage. If you have a need to include a lazy refresh state to your data, you can use this + * functionality. It works on the lazy React.setState functionality. + * + * Note: When using, you're giving up immediate setState, so you'll want to call the setStateLater + * function immediately to get back that functionality. + * + * Example: + * ``` + * React.useCallback(() => + * new Promise(() => { + * MyAPICall().then((...) => + * resolve((setStateLater) => { // << providing a function instead of the value + * setStateLater({ ...someInitialData }) + * // ...some time later, after more calls / in a callback / etc + * setStateLater((lastState) => ({ ...lastState, data: additionalData })) + * }) + * ) + * }) + * ); + * ``` + */ +export type FetchStateCallbackPromiseAdHoc = FetchStateCallbackPromiseReturn< + Promise> +>; + +export type FetchOptions = { + /** To enable auto refresh */ + refreshRate: number; + /** + * Makes your promise pure from the sense of if it changes you do not want the previous data. When + * you recompute your fetchCallbackPromise, do you want to drop the values stored? This will + * reset everything; result, loaded, & error state. Intended purpose is if your promise is keyed + * off of a value that if it changes you should drop all data as it's fundamentally a different + * thing - sharing old state is misleading. + * + * Note: Doing this could have undesired side effects. Consider your hook's dependents and the + * state of your data. + * Note: This is only read as initial value; changes do nothing. + */ + initialPromisePurity: boolean; +}; + +/** + * A boilerplate helper utility. Given a callback that returns a promise, it will store state and + * handle refreshes on intervals as needed. + * + * Note: Your callback *should* support the opts property so the call can be cancelled. + */ +const useFetchState = ( + /** React.useCallback result. */ + fetchCallbackPromise: FetchStateCallbackPromise>, + /** + * A preferred default states - this is ignored after the first render + * Note: This is only read as initial value; changes do nothing. + */ + initialDefaultState: Type, + /** Configurable features */ + { refreshRate = 0, initialPromisePurity = false }: Partial = {}, +): FetchState => { + const initialDefaultStateRef = React.useRef(initialDefaultState); + const [result, setResult] = React.useState(initialDefaultState); + const [loaded, setLoaded] = React.useState(false); + const [loadError, setLoadError] = React.useState(undefined); + const abortCallbackRef = React.useRef<() => void>(() => undefined); + const changePendingRef = React.useRef(true); + + /** Setup on initial hook a singular reset function. DefaultState & resetDataOnNewPromise are initial render states. */ + const cleanupRef = React.useRef(() => { + if (initialPromisePurity) { + setResult(initialDefaultState); + setLoaded(false); + setLoadError(undefined); + } + }); + + React.useEffect(() => { + cleanupRef.current(); + }, [fetchCallbackPromise]); + + const call = React.useCallback<() => [Promise, () => void]>(() => { + let alreadyAborted = false; + const abortController = new AbortController(); + + /** Note: this promise cannot "catch" beyond this instance -- unless a runtime error. */ + const doRequest = () => + fetchCallbackPromise({ signal: abortController.signal }) + .then((r) => { + changePendingRef.current = false; + if (alreadyAborted) { + return undefined; + } + + if (r === undefined) { + // Undefined is an unacceptable response. If you want "nothing", pass `null` -- this is likely an API issue though. + // eslint-disable-next-line no-console + console.error( + 'useFetchState Error: Got undefined back from a promise. This is likely an error with your call. Preventing setting.', + ); + return undefined; + } + + setLoadError(undefined); + if (isAdHocUpdate(r)) { + r((setState: SetStateLazy) => { + if (alreadyAborted) { + return undefined; + } + + setResult(setState); + setLoaded(true); + return undefined; + }); + return undefined; + } + + setResult(r); + setLoaded(true); + + return r; + }) + .catch((e) => { + changePendingRef.current = false; + if (alreadyAborted) { + return undefined; + } + + if (isCommonStateError(e)) { + return undefined; + } + setLoadError(e); + return undefined; + }); + + const unload = () => { + changePendingRef.current = false; + if (alreadyAborted) { + return; + } + + alreadyAborted = true; + abortController.abort(); + }; + + return [doRequest(), unload]; + }, [fetchCallbackPromise]); + + // Use a memmo to update the `changePendingRef` immediately on change. + React.useMemo(() => { + changePendingRef.current = true; + // React to changes to the `call` reference. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [call]); + + React.useEffect(() => { + let interval: ReturnType; + + const callAndSave = () => { + const [, unload] = call(); + abortCallbackRef.current = unload; + }; + callAndSave(); + + if (refreshRate > 0) { + interval = setInterval(() => { + abortCallbackRef.current(); + callAndSave(); + }, refreshRate); + } + + return () => { + clearInterval(interval); + abortCallbackRef.current(); + }; + }, [call, refreshRate]); + + // Use a reference for `call` to ensure a stable reference to `refresh` is always returned + const callRef = React.useRef(call); + callRef.current = call; + + const refresh = React.useCallback>(() => { + abortCallbackRef.current(); + const [callPromise, unload] = callRef.current(); + abortCallbackRef.current = unload; + return callPromise; + }, []); + + // Return the default reset state if a change is pending and initialPromisePurity is true + if (initialPromisePurity && changePendingRef.current) { + return [initialDefaultStateRef.current, false, undefined, refresh]; + } + + return [result, loaded, loadError, refresh]; +}; + +export default useFetchState; diff --git a/workspaces/frontend/tsconfig.json b/workspaces/frontend/tsconfig.json index f2f8bf8d..b7e5ca62 100644 --- a/workspaces/frontend/tsconfig.json +++ b/workspaces/frontend/tsconfig.json @@ -5,7 +5,10 @@ "outDir": "dist", "module": "esnext", "target": "ES6", - "lib": ["es6", "dom"], + "lib": [ + "es6", + "dom" + ], "sourceMap": true, "jsx": "react", "moduleResolution": "node", @@ -19,11 +22,22 @@ "allowSyntheticDefaultImports": true, "strict": true, "paths": { - "~/*": ["./*"] + "~/*": [ + "./*" + ] }, "importHelpers": true, "skipLibCheck": true }, - "include": ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js"], - "exclude": ["node_modules"] -} + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.jsx", + "**/*.js" + ], + "exclude": [ + "node_modules", + "src/__tests__/cypress" + ] + +} \ No newline at end of file