From 1dbdf2518a6efc541cf64beb0526c5e9a918ffd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Wed, 7 Aug 2024 19:15:20 +0200 Subject: [PATCH 01/76] feat(pci-instance): add package first configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../manager/apps/pci-instance/.eslintrc.cjs | 24 +++++ packages/manager/apps/pci-instance/.gitignore | 25 ++++++ packages/manager/apps/pci-instance/README.md | 1 + packages/manager/apps/pci-instance/index.html | 12 +++ .../manager/apps/pci-instance/package.json | 70 +++++++++++++++ .../apps/pci-instance/postcss.config.cjs | 6 ++ .../apps/pci-instance/tailwind.config.js | 13 +++ yarn.lock | 87 +++++++++++++++++-- 8 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 packages/manager/apps/pci-instance/.eslintrc.cjs create mode 100644 packages/manager/apps/pci-instance/.gitignore create mode 100644 packages/manager/apps/pci-instance/README.md create mode 100644 packages/manager/apps/pci-instance/index.html create mode 100644 packages/manager/apps/pci-instance/package.json create mode 100644 packages/manager/apps/pci-instance/postcss.config.cjs create mode 100644 packages/manager/apps/pci-instance/tailwind.config.js diff --git a/packages/manager/apps/pci-instance/.eslintrc.cjs b/packages/manager/apps/pci-instance/.eslintrc.cjs new file mode 100644 index 000000000000..783e63c17e3f --- /dev/null +++ b/packages/manager/apps/pci-instance/.eslintrc.cjs @@ -0,0 +1,24 @@ +module.exports = { + extends: [ + '../../../../.eslintrc.js', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + 'plugin:prettier/recommended', + ], + settings: { + react: { + version: 'detect', + }, + }, + globals: { + __VERSION__: true, + }, + + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { ignoreRestSiblings: true }, + ], + }, +}; diff --git a/packages/manager/apps/pci-instance/.gitignore b/packages/manager/apps/pci-instance/.gitignore new file mode 100644 index 000000000000..785a3087cb53 --- /dev/null +++ b/packages/manager/apps/pci-instance/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/manager/apps/pci-instance/README.md b/packages/manager/apps/pci-instance/README.md new file mode 100644 index 000000000000..03b8e24e95bb --- /dev/null +++ b/packages/manager/apps/pci-instance/README.md @@ -0,0 +1 @@ +# PCI Instance diff --git a/packages/manager/apps/pci-instance/index.html b/packages/manager/apps/pci-instance/index.html new file mode 100644 index 000000000000..fedc5e25f607 --- /dev/null +++ b/packages/manager/apps/pci-instance/index.html @@ -0,0 +1,12 @@ + + + + + + OVHcloud + + +
+ + + diff --git a/packages/manager/apps/pci-instance/package.json b/packages/manager/apps/pci-instance/package.json new file mode 100644 index 000000000000..17afb0cce89f --- /dev/null +++ b/packages/manager/apps/pci-instance/package.json @@ -0,0 +1,70 @@ +{ + "name": "@ovh-ux/manager-pci-instance-app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --project tsconfig.build.json && vite build", + "coverage": "vitest run --coverage", + "dev": "vite", + "lint": "eslint ./src", + "start": "lerna exec --stream --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run build --if-present", + "start:dev": "lerna exec --stream --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run dev --if-present", + "start:watch": "lerna exec --stream --parallel --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run dev:watch --if-present", + "test": "vitest run", + "test:watch": "vitest --watch", + "type:check": "tsc --noEmit" + }, + "dependencies": { + "@ovh-ux/manager-config": "^7.3.2", + "@ovh-ux/manager-core-api": "^0.8.0", + "@ovh-ux/manager-react-core-application": "^0.10.0", + "@ovh-ux/manager-react-shell-client": "^0.7.0", + "@ovh-ux/manager-tailwind-config": "^0.2.0", + "@ovhcloud/manager-components": "^1.26.0", + "@ovhcloud/ods-common-core": "17.2.2", + "@ovhcloud/ods-common-stencil": "17.2.2", + "@ovhcloud/ods-common-theming": "17.2.2", + "@ovhcloud/ods-components": "17.2.2", + "@ovhcloud/ods-theme-blue-jeans": "17.2.2", + "@tanstack/react-query": "^5.51.21", + "axios": "^1.6.8", + "element-internals-polyfill": "^1.3.10", + "i18next": "^23.8.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^14.0.5", + "react-router-dom": "^6.3.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "@ovh-ux/manager-vite-config": "^0.8.0", + "@tanstack/react-query-devtools": "^5.51.21", + "@testing-library/dom": "^9.3.4", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.12", + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@types/react-query": "^1.2.9", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^1.2.2", + "autoprefixer": "^10.4.17", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.35.0", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.4", + "vite": "^5.2.13", + "vitest": "^1.2.0" + }, + "regions": [ + "CA", + "EU", + "US" + ], + "universes": [ + "@ovh-ux/manager-public-cloud" + ] +} diff --git a/packages/manager/apps/pci-instance/postcss.config.cjs b/packages/manager/apps/pci-instance/postcss.config.cjs new file mode 100644 index 000000000000..12a703d900da --- /dev/null +++ b/packages/manager/apps/pci-instance/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/manager/apps/pci-instance/tailwind.config.js b/packages/manager/apps/pci-instance/tailwind.config.js new file mode 100644 index 000000000000..ac500dd0cc38 --- /dev/null +++ b/packages/manager/apps/pci-instance/tailwind.config.js @@ -0,0 +1,13 @@ +import config from '@ovh-ux/manager-tailwind-config'; + +/** @type {import('tailwindcss').Config} */ +module.exports = { + ...config, + content: [ + './src/**/*.{js,jsx,ts,tsx}', + '../../../manager-components/src/**/*.{js,jsx,ts,tsx}', + ], + corePlugins: { + preflight: false, + }, +}; diff --git a/yarn.lock b/yarn.lock index 83f0345a0b63..bc699c3723f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10963,7 +10963,7 @@ array-ify@^1.0.0: resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" integrity sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng== -array-includes@^3.1.6, array-includes@^3.1.7: +array-includes@^3.1.6, array-includes@^3.1.7, array-includes@^3.1.8: version "3.1.8" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== @@ -11002,7 +11002,7 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ== -array.prototype.findlast@^1.2.4: +array.prototype.findlast@^1.2.4, array.prototype.findlast@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" integrity sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ== @@ -11067,6 +11067,17 @@ array.prototype.tosorted@^1.1.1, array.prototype.tosorted@^1.1.3: es-errors "^1.1.0" es-shim-unscopables "^1.0.2" +array.prototype.tosorted@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz#fe954678ff53034e717ea3352a03f0b0b86f7ffc" + integrity sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-shim-unscopables "^1.0.2" + arraybuffer.prototype.slice@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" @@ -14673,7 +14684,7 @@ error-stack-parser@^2.0.6, error-stack-parser@^2.1.4: dependencies: stackframe "^1.3.4" -es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.1, es-abstract@^1.23.2: +es-abstract@^1.17.5, es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.1, es-abstract@^1.23.2, es-abstract@^1.23.3: version "1.23.3" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== @@ -14772,6 +14783,26 @@ es-iterator-helpers@^1.0.17: iterator.prototype "^1.1.2" safe-array-concat "^1.1.2" +es-iterator-helpers@^1.0.19: + version "1.0.19" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz#117003d0e5fec237b4b5c08aded722e0c6d50ca8" + integrity sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-set-tostringtag "^2.0.3" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + globalthis "^1.0.3" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + internal-slot "^1.0.7" + iterator.prototype "^1.1.2" + safe-array-concat "^1.1.2" + es-module-lexer@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" @@ -15221,6 +15252,11 @@ eslint-plugin-promise@6.1.1: resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz#269a3e2772f62875661220631bd4dafcb4083816" integrity sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig== +eslint-plugin-react-hooks@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" + integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== + eslint-plugin-react@7.33.1: version "7.33.1" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.33.1.tgz#bc27cccf860ae45413a4a4150bf0977345c1ceab" @@ -15266,6 +15302,30 @@ eslint-plugin-react@^7.20.3: semver "^6.3.1" string.prototype.matchall "^4.0.10" +eslint-plugin-react@^7.35.0: + version "7.35.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.35.0.tgz#00b1e4559896710e58af6358898f2ff917ea4c41" + integrity sha512-v501SSMOWv8gerHkk+IIQBkcGRGrO2nfybfj5pLxuJNFTPxxA3PSryhXTK+9pNbtkggheDdsC0E9Q8CuPk6JKA== + dependencies: + array-includes "^3.1.8" + array.prototype.findlast "^1.2.5" + array.prototype.flatmap "^1.3.2" + array.prototype.tosorted "^1.1.4" + doctrine "^2.1.0" + es-iterator-helpers "^1.0.19" + estraverse "^5.3.0" + hasown "^2.0.2" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.8" + object.fromentries "^2.0.8" + object.values "^1.2.0" + prop-types "^15.8.1" + resolve "^2.0.0-next.5" + semver "^6.3.1" + string.prototype.matchall "^4.0.11" + string.prototype.repeat "^1.0.0" + eslint-plugin-storybook@0.6.13: version "0.6.13" resolved "https://registry.yarnpkg.com/eslint-plugin-storybook/-/eslint-plugin-storybook-0.6.13.tgz#897a9f6a9bb88c63b02f05850f30c28a9848a3f7" @@ -22461,7 +22521,7 @@ object.defaults@^1.1.0: for-own "^1.0.0" isobject "^3.0.0" -object.entries@^1.1.2, object.entries@^1.1.6, object.entries@^1.1.7: +object.entries@^1.1.2, object.entries@^1.1.6, object.entries@^1.1.7, object.entries@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.8.tgz#bffe6f282e01f4d17807204a24f8edd823599c41" integrity sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ== @@ -22470,7 +22530,7 @@ object.entries@^1.1.2, object.entries@^1.1.6, object.entries@^1.1.7: define-properties "^1.2.1" es-object-atoms "^1.0.0" -object.fromentries@^2.0.6, object.fromentries@^2.0.7: +object.fromentries@^2.0.6, object.fromentries@^2.0.7, object.fromentries@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== @@ -22513,7 +22573,7 @@ object.pick@^1.3.0: dependencies: isobject "^3.0.1" -object.values@^1.1.6, object.values@^1.1.7: +object.values@^1.1.6, object.values@^1.1.7, object.values@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b" integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ== @@ -26563,7 +26623,12 @@ string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -string.prototype.matchall@^4.0.10, string.prototype.matchall@^4.0.8: +string.fromcodepoint@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/string.fromcodepoint/-/string.fromcodepoint-0.2.1.tgz#8d978333c0bc92538f50f383e4888f3e5619d653" + integrity sha512-n69H31OnxSGSZyZbgBlvYIXlrMhJQ0dQAX1js1QDhpaUH6zmU3QYlj07bCwCNlPOu3oRXIubGPl2gDGnHsiCqg== + +string.prototype.matchall@^4.0.10, string.prototype.matchall@^4.0.11, string.prototype.matchall@^4.0.8: version "4.0.11" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a" integrity sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg== @@ -26591,6 +26656,14 @@ string.prototype.padend@^3.0.0: es-abstract "^1.23.2" es-object-atoms "^1.0.0" +string.prototype.repeat@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz#e90872ee0308b29435aa26275f6e1b762daee01a" + integrity sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string.prototype.trim@^1.2.9: version "1.2.9" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" From 6c71a329bd27aecefc14e9e6ca252cd43abc6f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 8 Aug 2024 09:51:11 +0200 Subject: [PATCH 02/76] feat(pci-instance): add eslint-plugin-react-hooks to main package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b36349e88a94..02d3dcabc123 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "eslint-plugin-import": "^2.18.2", "eslint-plugin-markdown": "^1.0.0", "eslint-plugin-prettier": "^3.1.1", + "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-tsdoc": "^0.2.17", "esm": "^3.2.25", "execa": "^2.0.5", From b1ef15f16fd941a3055294508456354d6afa4c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 8 Aug 2024 09:57:26 +0200 Subject: [PATCH 03/76] feat(pci-instance): add typescript config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../apps/pci-instance/tsconfig.build.json | 6 +++++ .../manager/apps/pci-instance/tsconfig.json | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 packages/manager/apps/pci-instance/tsconfig.build.json create mode 100644 packages/manager/apps/pci-instance/tsconfig.json diff --git a/packages/manager/apps/pci-instance/tsconfig.build.json b/packages/manager/apps/pci-instance/tsconfig.build.json new file mode 100644 index 000000000000..266776ad08de --- /dev/null +++ b/packages/manager/apps/pci-instance/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "strict": false // to enable after `manager-components` has been cleaned up + } +} diff --git a/packages/manager/apps/pci-instance/tsconfig.json b/packages/manager/apps/pci-instance/tsconfig.json new file mode 100644 index 000000000000..831d6f78cb58 --- /dev/null +++ b/packages/manager/apps/pci-instance/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"], + "react": ["./node_modules/@types/react"] + }, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["src"], + "exclude": ["node_modules", "public", "types", "src/**/*.spec.ts"] +} From 5dd32b135017c2987c9f53f504375eefb3c5d101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 8 Aug 2024 10:09:41 +0200 Subject: [PATCH 04/76] feat(pci-instance): add vite config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../apps/pci-instance/src/vite-env.d.ts | 1 + .../manager/apps/pci-instance/src/vite-hmr.ts | 5 ++++ .../manager/apps/pci-instance/vite.config.ts | 18 ++++++++++++ .../apps/pci-instance/vitest.config.js | 28 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 packages/manager/apps/pci-instance/src/vite-env.d.ts create mode 100644 packages/manager/apps/pci-instance/src/vite-hmr.ts create mode 100644 packages/manager/apps/pci-instance/vite.config.ts create mode 100644 packages/manager/apps/pci-instance/vitest.config.js diff --git a/packages/manager/apps/pci-instance/src/vite-env.d.ts b/packages/manager/apps/pci-instance/src/vite-env.d.ts new file mode 100644 index 000000000000..11f02fe2a006 --- /dev/null +++ b/packages/manager/apps/pci-instance/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/manager/apps/pci-instance/src/vite-hmr.ts b/packages/manager/apps/pci-instance/src/vite-hmr.ts new file mode 100644 index 000000000000..473d87630039 --- /dev/null +++ b/packages/manager/apps/pci-instance/src/vite-hmr.ts @@ -0,0 +1,5 @@ +if (import.meta.hot) { + import.meta.hot.on('iframe-reload', () => { + window.location.reload(); + }); +} diff --git a/packages/manager/apps/pci-instance/vite.config.ts b/packages/manager/apps/pci-instance/vite.config.ts new file mode 100644 index 000000000000..e3ad2b027ea2 --- /dev/null +++ b/packages/manager/apps/pci-instance/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import { getBaseConfig } from '@ovh-ux/manager-vite-config'; +import tailwindcss from 'tailwindcss'; + +const baseConfig = getBaseConfig({}); + +export default defineConfig({ + ...baseConfig, + root: '', + css: { + postcss: { + plugins: [tailwindcss], + }, + }, + resolve: { + ...baseConfig.resolve, + }, +}); diff --git a/packages/manager/apps/pci-instance/vitest.config.js b/packages/manager/apps/pci-instance/vitest.config.js new file mode 100644 index 000000000000..46d98982d789 --- /dev/null +++ b/packages/manager/apps/pci-instance/vitest.config.js @@ -0,0 +1,28 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + coverage: { + include: ['src'], + exclude: [ + 'src/vite-*.ts', + 'src/App.tsx', + 'src/core/ShellRoutingSync.tsx', + 'src/main.tsx', + 'src/routes.tsx', + ], + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + mainFields: ['module'], + }, +}); From 67cf162604d95764839d95f15b5cee6777cfa270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 8 Aug 2024 10:40:50 +0200 Subject: [PATCH 05/76] feat(pci-instance): init React app tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../manager/apps/pci-instance/src/App.tsx | 19 +++++++++ .../apps/pci-instance/src/api/queryClient.ts | 11 +++++ .../manager/apps/pci-instance/src/index.css | 7 ++++ .../manager/apps/pci-instance/src/main.tsx | 42 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 packages/manager/apps/pci-instance/src/App.tsx create mode 100644 packages/manager/apps/pci-instance/src/api/queryClient.ts create mode 100644 packages/manager/apps/pci-instance/src/index.css create mode 100644 packages/manager/apps/pci-instance/src/main.tsx diff --git a/packages/manager/apps/pci-instance/src/App.tsx b/packages/manager/apps/pci-instance/src/App.tsx new file mode 100644 index 000000000000..63f122a528c1 --- /dev/null +++ b/packages/manager/apps/pci-instance/src/App.tsx @@ -0,0 +1,19 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { odsSetup } from '@ovhcloud/ods-common-core'; +import queryClient from '@/api/queryClient'; +import '@ovhcloud/ods-theme-blue-jeans'; +import Router from './components/router/Router'; + +odsSetup(); + +function App() { + return ( + + + + + ); +} + +export default App; diff --git a/packages/manager/apps/pci-instance/src/api/queryClient.ts b/packages/manager/apps/pci-instance/src/api/queryClient.ts new file mode 100644 index 000000000000..94100b5328ad --- /dev/null +++ b/packages/manager/apps/pci-instance/src/api/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 300_000, + }, + }, +}); + +export default queryClient; diff --git a/packages/manager/apps/pci-instance/src/index.css b/packages/manager/apps/pci-instance/src/index.css new file mode 100644 index 000000000000..d0e30d40cd1b --- /dev/null +++ b/packages/manager/apps/pci-instance/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.application { + @apply mx-11 mt-8; +} diff --git a/packages/manager/apps/pci-instance/src/main.tsx b/packages/manager/apps/pci-instance/src/main.tsx new file mode 100644 index 000000000000..cce540e1d17b --- /dev/null +++ b/packages/manager/apps/pci-instance/src/main.tsx @@ -0,0 +1,42 @@ +import { + initI18n, + initShellContext, + ShellContext, +} from '@ovh-ux/manager-react-shell-client'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import '@/vite-hmr'; +import App from './App'; + +import './index.css'; + +const init = async ( + appName: string, + { reloadOnLocaleChange } = { reloadOnLocaleChange: false }, +) => { + const context = await initShellContext(appName); + + const region = context.environment.getRegion(); + try { + await import(`./config-${region}.js`); + } catch { + // nothing to do + } + + await initI18n({ + context, + reloadOnLocaleChange, + ns: ['common'], + defaultNS: 'common', + }); + + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + , + ); +}; + +init('pci-instance'); From 330c3fcfd12ad8089da0925ada70baf7411d4228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 8 Aug 2024 10:43:56 +0200 Subject: [PATCH 06/76] feat(pci-instance): add base components, page and utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../src/components/router/Router.tsx | 9 ++++ .../pci-instance/src/core/HidePreloader.tsx | 12 +++++ .../src/core/ShellRoutingSync.tsx | 18 +++++++ .../apps/pci-instance/src/pages/Layout.tsx | 47 +++++++++++++++++++ .../manager/apps/pci-instance/src/routes.tsx | 21 +++++++++ .../apps/pci-instance/src/utils/index.ts | 34 ++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 packages/manager/apps/pci-instance/src/components/router/Router.tsx create mode 100644 packages/manager/apps/pci-instance/src/core/HidePreloader.tsx create mode 100644 packages/manager/apps/pci-instance/src/core/ShellRoutingSync.tsx create mode 100644 packages/manager/apps/pci-instance/src/pages/Layout.tsx create mode 100644 packages/manager/apps/pci-instance/src/routes.tsx create mode 100644 packages/manager/apps/pci-instance/src/utils/index.ts diff --git a/packages/manager/apps/pci-instance/src/components/router/Router.tsx b/packages/manager/apps/pci-instance/src/components/router/Router.tsx new file mode 100644 index 000000000000..eacb4e09f9ff --- /dev/null +++ b/packages/manager/apps/pci-instance/src/components/router/Router.tsx @@ -0,0 +1,9 @@ +import { FC } from 'react'; +import { createHashRouter, RouterProvider } from 'react-router-dom'; +import routes from '@/routes'; + +const router = createHashRouter(routes); + +const Router: FC = () => ; + +export default Router; diff --git a/packages/manager/apps/pci-instance/src/core/HidePreloader.tsx b/packages/manager/apps/pci-instance/src/core/HidePreloader.tsx new file mode 100644 index 000000000000..d1c2c6d1df85 --- /dev/null +++ b/packages/manager/apps/pci-instance/src/core/HidePreloader.tsx @@ -0,0 +1,12 @@ +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { useContext, useEffect } from 'react'; + +export default function HidePreloader() { + const { ux } = useContext(ShellContext).shell; + + useEffect(() => { + ux.hidePreloader(); + }, [ux]); + + return null; +} diff --git a/packages/manager/apps/pci-instance/src/core/ShellRoutingSync.tsx b/packages/manager/apps/pci-instance/src/core/ShellRoutingSync.tsx new file mode 100644 index 000000000000..86e8afc52949 --- /dev/null +++ b/packages/manager/apps/pci-instance/src/core/ShellRoutingSync.tsx @@ -0,0 +1,18 @@ +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { useContext, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +export default function ShellRoutingSync() { + const location = useLocation(); + const { routing } = useContext(ShellContext).shell; + + useEffect(() => { + routing.stopListenForHashChange(); + }, [routing]); + + useEffect(() => { + routing.onHashChange(); + }, [location, routing]); + + return null; +} diff --git a/packages/manager/apps/pci-instance/src/pages/Layout.tsx b/packages/manager/apps/pci-instance/src/pages/Layout.tsx new file mode 100644 index 000000000000..67bbf5196944 --- /dev/null +++ b/packages/manager/apps/pci-instance/src/pages/Layout.tsx @@ -0,0 +1,47 @@ +import { Suspense, useContext, FC } from 'react'; +import { useRouteError, Outlet } from 'react-router-dom'; +import { ErrorBanner } from '@ovhcloud/manager-components'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { mapUnknownErrorToBannerError } from '@/utils'; +import HidePreloader from '@/core/HidePreloader'; +import ShellRoutingSync from '@/core/ShellRoutingSync'; + +const Layout: FC = () => ( +
+ + + + +
+); + +export const ErrorBoundary = () => { + const routerError = useRouteError(); + const errorBannerError = mapUnknownErrorToBannerError(routerError); + + const nav = useContext(ShellContext).shell.navigation; + + const redirectionApplication = 'public-cloud'; + + const navigateToHomePage = () => { + nav.navigateTo(redirectionApplication, '', {}); + }; + + const reloadPage = () => { + nav.reload(); + }; + + return ( + + + + + + ); +}; + +export default Layout; diff --git a/packages/manager/apps/pci-instance/src/routes.tsx b/packages/manager/apps/pci-instance/src/routes.tsx new file mode 100644 index 000000000000..6166eb82164b --- /dev/null +++ b/packages/manager/apps/pci-instance/src/routes.tsx @@ -0,0 +1,21 @@ +const lazyRouteConfig = (importFn: CallableFunction) => ({ + lazy: async () => { + const { default: moduleDefault, ...moduleExports } = await importFn(); + + return { + Component: moduleDefault, + ...moduleExports, + }; + }, +}); + +export default [ + { + path: '/', + ...lazyRouteConfig(() => import('@/pages/Layout')), + }, + { + path: '*', + element:

Not found page

, + }, +]; diff --git a/packages/manager/apps/pci-instance/src/utils/index.ts b/packages/manager/apps/pci-instance/src/utils/index.ts new file mode 100644 index 000000000000..8289e14660c3 --- /dev/null +++ b/packages/manager/apps/pci-instance/src/utils/index.ts @@ -0,0 +1,34 @@ +import { ErrorBannerProps } from '@ovhcloud/manager-components'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { ErrorResponse, isRouteErrorResponse } from 'react-router-dom'; +import { AxiosError } from 'axios'; + +type RouterErrorResponse = Pick< + ErrorResponse, + 'data' | 'status' | 'statusText' +> & { + internal: boolean; +}; + +const isApiErrorResponse = (error: unknown): error is ApiError => + error instanceof AxiosError && + (error as AxiosError<{ message: string }>).response?.data?.message !== + undefined; + +export const mapUnknownErrorToBannerError = ( + error: unknown, +): ErrorBannerProps['error'] => { + if (isApiErrorResponse(error) && error.response) { + const { statusText, config, request, ...rest } = error.response; + return rest; + } + if (isRouteErrorResponse(error)) { + const { + statusText, + internal, + ...rest + } = (error as unknown) as RouterErrorResponse; + return rest; + } + return {}; +}; From 13f0c1b6a683d35c4c176ff4c879439cafcd708d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 8 Aug 2024 10:45:21 +0200 Subject: [PATCH 07/76] test(pci-instance): add test suite for utility functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../apps/pci-instance/src/utils/utils.spec.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/manager/apps/pci-instance/src/utils/utils.spec.ts diff --git a/packages/manager/apps/pci-instance/src/utils/utils.spec.ts b/packages/manager/apps/pci-instance/src/utils/utils.spec.ts new file mode 100644 index 000000000000..1b4875f93c03 --- /dev/null +++ b/packages/manager/apps/pci-instance/src/utils/utils.spec.ts @@ -0,0 +1,96 @@ +import { AxiosError, AxiosHeaders } from 'axios'; +import { describe, expect, test } from 'vitest'; +import { ErrorBannerProps } from '@ovhcloud/manager-components'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { mapUnknownErrorToBannerError } from './index'; + +describe('Utility functions', () => { + describe('Considering the mapUnknownErrorToBannerError function', () => { + type Data = { + rawError: unknown; + expectedOutputError: ErrorBannerProps['error']; + }; + const fakeRequest = { path: '/foo' }; + const fakeHeaders = new AxiosHeaders(); + const fakeConfig = { + url: 'https://foo.bar', + headers: fakeHeaders, + }; + + const fakeApiError1 = (new AxiosError( + 'Message 1', + 'Code 1', + fakeConfig, + fakeRequest, + { + status: 404, + data: { foo: 'bar' }, + statusText: '', + config: fakeConfig, + headers: fakeHeaders, + }, + ) as unknown) as ApiError; + + const fakeApiError2 = new AxiosError( + 'Message 2', + 'Code 2', + fakeConfig, + fakeRequest, + { + status: 404, + data: { message: 'Resource not found' }, + statusText: '', + config: fakeConfig, + headers: fakeHeaders, + }, + ) as ApiError; + + const fakeApiError3 = new AxiosError( + 'Message 2', + 'Code 2', + fakeConfig, + fakeRequest, + ) as ApiError; + + const fakeRouterError1 = { + status: 404, + statusText: '', + data: { + message: 'Resource not found', + }, + internal: true, + }; + + const fakeRouterError2 = { + status: 404, + data: { + message: 'Resource not found', + }, + }; + + describe.each` + rawError | expectedOutputError + ${{}} | ${{}} + ${null} | ${{}} + ${undefined} | ${{}} + ${fakeApiError1} | ${{}} + ${fakeApiError2} | ${{ status: 404, data: { message: 'Resource not found' }, headers: fakeHeaders }} + ${fakeApiError3} | ${{}} + ${fakeRouterError1} | ${{ data: { message: 'Resource not found' }, status: 404 }} + ${fakeRouterError2} | ${{}} + `( + 'Given an error <$rawError> thrown through a route', + ({ rawError, expectedOutputError }: Data) => { + describe('When calling mapUnknownErrorToBannerError()', () => { + test(`Then, expect the output error to be '${JSON.stringify( + expectedOutputError, + )}'`, () => { + expect(mapUnknownErrorToBannerError(rawError)).toStrictEqual( + expectedOutputError, + ); + }); + }); + }, + ); + }); +}); From ad92ddac844d1c2a44ca8c7d507afe58a03dfd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 27 Aug 2024 10:20:39 +0200 Subject: [PATCH 08/76] feat(pci-instance): follow architecture guidelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- packages/manager/apps/pci-instance/src/App.tsx | 4 ++-- .../{core => components/hidePreloader}/HidePreloader.tsx | 0 .../src/{core => components/shell}/ShellRoutingSync.tsx | 0 packages/manager/apps/pci-instance/src/index.css | 4 ---- packages/manager/apps/pci-instance/src/pages/Layout.tsx | 6 +++--- .../manager/apps/pci-instance/src/{api => }/queryClient.ts | 0 .../src/{components/router => routes}/Router.tsx | 2 +- .../manager/apps/pci-instance/src/{ => routes}/routes.tsx | 0 8 files changed, 6 insertions(+), 10 deletions(-) rename packages/manager/apps/pci-instance/src/{core => components/hidePreloader}/HidePreloader.tsx (100%) rename packages/manager/apps/pci-instance/src/{core => components/shell}/ShellRoutingSync.tsx (100%) rename packages/manager/apps/pci-instance/src/{api => }/queryClient.ts (100%) rename packages/manager/apps/pci-instance/src/{components/router => routes}/Router.tsx (85%) rename packages/manager/apps/pci-instance/src/{ => routes}/routes.tsx (100%) diff --git a/packages/manager/apps/pci-instance/src/App.tsx b/packages/manager/apps/pci-instance/src/App.tsx index 63f122a528c1..d78153da41ea 100644 --- a/packages/manager/apps/pci-instance/src/App.tsx +++ b/packages/manager/apps/pci-instance/src/App.tsx @@ -1,9 +1,9 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { odsSetup } from '@ovhcloud/ods-common-core'; -import queryClient from '@/api/queryClient'; +import queryClient from '@/queryClient'; import '@ovhcloud/ods-theme-blue-jeans'; -import Router from './components/router/Router'; +import Router from './routes/Router'; odsSetup(); diff --git a/packages/manager/apps/pci-instance/src/core/HidePreloader.tsx b/packages/manager/apps/pci-instance/src/components/hidePreloader/HidePreloader.tsx similarity index 100% rename from packages/manager/apps/pci-instance/src/core/HidePreloader.tsx rename to packages/manager/apps/pci-instance/src/components/hidePreloader/HidePreloader.tsx diff --git a/packages/manager/apps/pci-instance/src/core/ShellRoutingSync.tsx b/packages/manager/apps/pci-instance/src/components/shell/ShellRoutingSync.tsx similarity index 100% rename from packages/manager/apps/pci-instance/src/core/ShellRoutingSync.tsx rename to packages/manager/apps/pci-instance/src/components/shell/ShellRoutingSync.tsx diff --git a/packages/manager/apps/pci-instance/src/index.css b/packages/manager/apps/pci-instance/src/index.css index d0e30d40cd1b..b5c61c956711 100644 --- a/packages/manager/apps/pci-instance/src/index.css +++ b/packages/manager/apps/pci-instance/src/index.css @@ -1,7 +1,3 @@ @tailwind base; @tailwind components; @tailwind utilities; - -.application { - @apply mx-11 mt-8; -} diff --git a/packages/manager/apps/pci-instance/src/pages/Layout.tsx b/packages/manager/apps/pci-instance/src/pages/Layout.tsx index 67bbf5196944..e59e72eb748c 100644 --- a/packages/manager/apps/pci-instance/src/pages/Layout.tsx +++ b/packages/manager/apps/pci-instance/src/pages/Layout.tsx @@ -3,11 +3,11 @@ import { useRouteError, Outlet } from 'react-router-dom'; import { ErrorBanner } from '@ovhcloud/manager-components'; import { ShellContext } from '@ovh-ux/manager-react-shell-client'; import { mapUnknownErrorToBannerError } from '@/utils'; -import HidePreloader from '@/core/HidePreloader'; -import ShellRoutingSync from '@/core/ShellRoutingSync'; +import HidePreloader from '@/components/hidePreloader/HidePreloader'; +import ShellRoutingSync from '@/components/shell/ShellRoutingSync'; const Layout: FC = () => ( -
+
diff --git a/packages/manager/apps/pci-instance/src/api/queryClient.ts b/packages/manager/apps/pci-instance/src/queryClient.ts similarity index 100% rename from packages/manager/apps/pci-instance/src/api/queryClient.ts rename to packages/manager/apps/pci-instance/src/queryClient.ts diff --git a/packages/manager/apps/pci-instance/src/components/router/Router.tsx b/packages/manager/apps/pci-instance/src/routes/Router.tsx similarity index 85% rename from packages/manager/apps/pci-instance/src/components/router/Router.tsx rename to packages/manager/apps/pci-instance/src/routes/Router.tsx index eacb4e09f9ff..3aa80335f7d7 100644 --- a/packages/manager/apps/pci-instance/src/components/router/Router.tsx +++ b/packages/manager/apps/pci-instance/src/routes/Router.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { createHashRouter, RouterProvider } from 'react-router-dom'; -import routes from '@/routes'; +import routes from '@/routes/routes'; const router = createHashRouter(routes); diff --git a/packages/manager/apps/pci-instance/src/routes.tsx b/packages/manager/apps/pci-instance/src/routes/routes.tsx similarity index 100% rename from packages/manager/apps/pci-instance/src/routes.tsx rename to packages/manager/apps/pci-instance/src/routes/routes.tsx From 9804db9cfc9f7b250e1bd506d519f9f24312a4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 27 Aug 2024 15:34:38 +0200 Subject: [PATCH 09/76] feat(pci-instance): add translations folder and fr file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../pci-instance/public/translations/common/Messages_fr_FR.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/manager/apps/pci-instance/public/translations/common/Messages_fr_FR.json diff --git a/packages/manager/apps/pci-instance/public/translations/common/Messages_fr_FR.json b/packages/manager/apps/pci-instance/public/translations/common/Messages_fr_FR.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/packages/manager/apps/pci-instance/public/translations/common/Messages_fr_FR.json @@ -0,0 +1 @@ +{} From 442f5d855db741f923ec7996732d67d2c9f17afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:35:50 +0200 Subject: [PATCH 10/76] refactor(pci-instances): modify root folder to start with container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../{pci-instance => pci-instances}/.eslintrc.cjs | 0 .../{pci-instance => pci-instances}/.gitignore | 0 .../apps/{pci-instance => pci-instances}/README.md | 0 .../{pci-instance => pci-instances}/index.html | 0 .../{pci-instance => pci-instances}/package.json | 11 ++++++----- .../postcss.config.cjs | 0 .../public/translations/common/Messages_fr_FR.json | 0 .../{pci-instance => pci-instances}/src/App.tsx | 0 .../{pci-instance => pci-instances}/src/index.css | 0 .../{pci-instance => pci-instances}/src/main.tsx | 2 +- .../src/queryClient.ts | 0 .../src/routes/Router.tsx | 0 .../src/utils/index.ts | 14 ++++++++++++-- .../src/vite-env.d.ts | 0 .../src/vite-hmr.ts | 0 .../tailwind.config.js | 2 +- .../tsconfig.build.json | 0 .../{pci-instance => pci-instances}/tsconfig.json | 0 .../{pci-instance => pci-instances}/vite.config.ts | 0 .../vitest.config.js | 1 + 20 files changed, 21 insertions(+), 9 deletions(-) rename packages/manager/apps/{pci-instance => pci-instances}/.eslintrc.cjs (100%) rename packages/manager/apps/{pci-instance => pci-instances}/.gitignore (100%) rename packages/manager/apps/{pci-instance => pci-instances}/README.md (100%) rename packages/manager/apps/{pci-instance => pci-instances}/index.html (100%) rename packages/manager/apps/{pci-instance => pci-instances}/package.json (85%) rename packages/manager/apps/{pci-instance => pci-instances}/postcss.config.cjs (100%) rename packages/manager/apps/{pci-instance => pci-instances}/public/translations/common/Messages_fr_FR.json (100%) rename packages/manager/apps/{pci-instance => pci-instances}/src/App.tsx (100%) rename packages/manager/apps/{pci-instance => pci-instances}/src/index.css (100%) rename packages/manager/apps/{pci-instance => pci-instances}/src/main.tsx (97%) rename packages/manager/apps/{pci-instance => pci-instances}/src/queryClient.ts (100%) rename packages/manager/apps/{pci-instance => pci-instances}/src/routes/Router.tsx (100%) rename packages/manager/apps/{pci-instance => pci-instances}/src/utils/index.ts (71%) rename packages/manager/apps/{pci-instance => pci-instances}/src/vite-env.d.ts (100%) rename packages/manager/apps/{pci-instance => pci-instances}/src/vite-hmr.ts (100%) rename packages/manager/apps/{pci-instance => pci-instances}/tailwind.config.js (77%) rename packages/manager/apps/{pci-instance => pci-instances}/tsconfig.build.json (100%) rename packages/manager/apps/{pci-instance => pci-instances}/tsconfig.json (100%) rename packages/manager/apps/{pci-instance => pci-instances}/vite.config.ts (100%) rename packages/manager/apps/{pci-instance => pci-instances}/vitest.config.js (96%) diff --git a/packages/manager/apps/pci-instance/.eslintrc.cjs b/packages/manager/apps/pci-instances/.eslintrc.cjs similarity index 100% rename from packages/manager/apps/pci-instance/.eslintrc.cjs rename to packages/manager/apps/pci-instances/.eslintrc.cjs diff --git a/packages/manager/apps/pci-instance/.gitignore b/packages/manager/apps/pci-instances/.gitignore similarity index 100% rename from packages/manager/apps/pci-instance/.gitignore rename to packages/manager/apps/pci-instances/.gitignore diff --git a/packages/manager/apps/pci-instance/README.md b/packages/manager/apps/pci-instances/README.md similarity index 100% rename from packages/manager/apps/pci-instance/README.md rename to packages/manager/apps/pci-instances/README.md diff --git a/packages/manager/apps/pci-instance/index.html b/packages/manager/apps/pci-instances/index.html similarity index 100% rename from packages/manager/apps/pci-instance/index.html rename to packages/manager/apps/pci-instances/index.html diff --git a/packages/manager/apps/pci-instance/package.json b/packages/manager/apps/pci-instances/package.json similarity index 85% rename from packages/manager/apps/pci-instance/package.json rename to packages/manager/apps/pci-instances/package.json index 17afb0cce89f..aded20fca6ab 100644 --- a/packages/manager/apps/pci-instance/package.json +++ b/packages/manager/apps/pci-instances/package.json @@ -1,5 +1,5 @@ { - "name": "@ovh-ux/manager-pci-instance-app", + "name": "@ovh-ux/manager-pci-instances-app", "version": "0.0.0", "private": true, "type": "module", @@ -8,9 +8,9 @@ "coverage": "vitest run --coverage", "dev": "vite", "lint": "eslint ./src", - "start": "lerna exec --stream --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run build --if-present", - "start:dev": "lerna exec --stream --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run dev --if-present", - "start:watch": "lerna exec --stream --parallel --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run dev:watch --if-present", + "start": "lerna exec --stream --scope='@ovh-ux/manager-pci-instances-app' --include-dependencies -- npm run build --if-present", + "start:dev": "lerna exec --stream --scope='@ovh-ux/manager-pci-instances-app' --include-dependencies -- npm run dev --if-present", + "start:watch": "lerna exec --stream --parallel --scope='@ovh-ux/manager-pci-instances-app' --include-dependencies -- npm run dev:watch --if-present", "test": "vitest run", "test:watch": "vitest --watch", "type:check": "tsc --noEmit" @@ -18,10 +18,10 @@ "dependencies": { "@ovh-ux/manager-config": "^7.3.2", "@ovh-ux/manager-core-api": "^0.8.0", + "@ovh-ux/manager-react-components": "^1.26.0", "@ovh-ux/manager-react-core-application": "^0.10.0", "@ovh-ux/manager-react-shell-client": "^0.7.0", "@ovh-ux/manager-tailwind-config": "^0.2.0", - "@ovhcloud/manager-components": "^1.26.0", "@ovhcloud/ods-common-core": "17.2.2", "@ovhcloud/ods-common-stencil": "17.2.2", "@ovhcloud/ods-common-theming": "17.2.2", @@ -54,6 +54,7 @@ "autoprefixer": "^10.4.17", "eslint": "^8.57.0", "eslint-plugin-react": "^7.35.0", + "msw": "^2.4.1", "postcss": "^8.4.35", "tailwindcss": "^3.4.4", "vite": "^5.2.13", diff --git a/packages/manager/apps/pci-instance/postcss.config.cjs b/packages/manager/apps/pci-instances/postcss.config.cjs similarity index 100% rename from packages/manager/apps/pci-instance/postcss.config.cjs rename to packages/manager/apps/pci-instances/postcss.config.cjs diff --git a/packages/manager/apps/pci-instance/public/translations/common/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json similarity index 100% rename from packages/manager/apps/pci-instance/public/translations/common/Messages_fr_FR.json rename to packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json diff --git a/packages/manager/apps/pci-instance/src/App.tsx b/packages/manager/apps/pci-instances/src/App.tsx similarity index 100% rename from packages/manager/apps/pci-instance/src/App.tsx rename to packages/manager/apps/pci-instances/src/App.tsx diff --git a/packages/manager/apps/pci-instance/src/index.css b/packages/manager/apps/pci-instances/src/index.css similarity index 100% rename from packages/manager/apps/pci-instance/src/index.css rename to packages/manager/apps/pci-instances/src/index.css diff --git a/packages/manager/apps/pci-instance/src/main.tsx b/packages/manager/apps/pci-instances/src/main.tsx similarity index 97% rename from packages/manager/apps/pci-instance/src/main.tsx rename to packages/manager/apps/pci-instances/src/main.tsx index cce540e1d17b..9ed31bfe1529 100644 --- a/packages/manager/apps/pci-instance/src/main.tsx +++ b/packages/manager/apps/pci-instances/src/main.tsx @@ -39,4 +39,4 @@ const init = async ( ); }; -init('pci-instance'); +init('pci-instances'); diff --git a/packages/manager/apps/pci-instance/src/queryClient.ts b/packages/manager/apps/pci-instances/src/queryClient.ts similarity index 100% rename from packages/manager/apps/pci-instance/src/queryClient.ts rename to packages/manager/apps/pci-instances/src/queryClient.ts diff --git a/packages/manager/apps/pci-instance/src/routes/Router.tsx b/packages/manager/apps/pci-instances/src/routes/Router.tsx similarity index 100% rename from packages/manager/apps/pci-instance/src/routes/Router.tsx rename to packages/manager/apps/pci-instances/src/routes/Router.tsx diff --git a/packages/manager/apps/pci-instance/src/utils/index.ts b/packages/manager/apps/pci-instances/src/utils/index.ts similarity index 71% rename from packages/manager/apps/pci-instance/src/utils/index.ts rename to packages/manager/apps/pci-instances/src/utils/index.ts index 8289e14660c3..423ffd010387 100644 --- a/packages/manager/apps/pci-instance/src/utils/index.ts +++ b/packages/manager/apps/pci-instances/src/utils/index.ts @@ -1,4 +1,4 @@ -import { ErrorBannerProps } from '@ovhcloud/manager-components'; +import { ErrorBannerProps } from '@ovh-ux/manager-react-components'; import { ApiError } from '@ovh-ux/manager-core-api'; import { ErrorResponse, isRouteErrorResponse } from 'react-router-dom'; import { AxiosError } from 'axios'; @@ -10,7 +10,7 @@ type RouterErrorResponse = Pick< internal: boolean; }; -const isApiErrorResponse = (error: unknown): error is ApiError => +export const isApiErrorResponse = (error: unknown): error is ApiError => error instanceof AxiosError && (error as AxiosError<{ message: string }>).response?.data?.message !== undefined; @@ -32,3 +32,13 @@ export const mapUnknownErrorToBannerError = ( } return {}; }; + +export const instancesQueryKey = ( + projectId: string, + rest?: string[], +): string[] => [ + 'project', + projectId, + 'instances', + ...(rest && rest.length > 0 ? rest : []), +]; diff --git a/packages/manager/apps/pci-instance/src/vite-env.d.ts b/packages/manager/apps/pci-instances/src/vite-env.d.ts similarity index 100% rename from packages/manager/apps/pci-instance/src/vite-env.d.ts rename to packages/manager/apps/pci-instances/src/vite-env.d.ts diff --git a/packages/manager/apps/pci-instance/src/vite-hmr.ts b/packages/manager/apps/pci-instances/src/vite-hmr.ts similarity index 100% rename from packages/manager/apps/pci-instance/src/vite-hmr.ts rename to packages/manager/apps/pci-instances/src/vite-hmr.ts diff --git a/packages/manager/apps/pci-instance/tailwind.config.js b/packages/manager/apps/pci-instances/tailwind.config.js similarity index 77% rename from packages/manager/apps/pci-instance/tailwind.config.js rename to packages/manager/apps/pci-instances/tailwind.config.js index ac500dd0cc38..14998255815f 100644 --- a/packages/manager/apps/pci-instance/tailwind.config.js +++ b/packages/manager/apps/pci-instances/tailwind.config.js @@ -5,7 +5,7 @@ module.exports = { ...config, content: [ './src/**/*.{js,jsx,ts,tsx}', - '../../../manager-components/src/**/*.{js,jsx,ts,tsx}', + '../../../manager-react-components/src/**/*.{js,jsx,ts,tsx}', ], corePlugins: { preflight: false, diff --git a/packages/manager/apps/pci-instance/tsconfig.build.json b/packages/manager/apps/pci-instances/tsconfig.build.json similarity index 100% rename from packages/manager/apps/pci-instance/tsconfig.build.json rename to packages/manager/apps/pci-instances/tsconfig.build.json diff --git a/packages/manager/apps/pci-instance/tsconfig.json b/packages/manager/apps/pci-instances/tsconfig.json similarity index 100% rename from packages/manager/apps/pci-instance/tsconfig.json rename to packages/manager/apps/pci-instances/tsconfig.json diff --git a/packages/manager/apps/pci-instance/vite.config.ts b/packages/manager/apps/pci-instances/vite.config.ts similarity index 100% rename from packages/manager/apps/pci-instance/vite.config.ts rename to packages/manager/apps/pci-instances/vite.config.ts diff --git a/packages/manager/apps/pci-instance/vitest.config.js b/packages/manager/apps/pci-instances/vitest.config.js similarity index 96% rename from packages/manager/apps/pci-instance/vitest.config.js rename to packages/manager/apps/pci-instances/vitest.config.js index 46d98982d789..25a685f80d02 100644 --- a/packages/manager/apps/pci-instance/vitest.config.js +++ b/packages/manager/apps/pci-instances/vitest.config.js @@ -16,6 +16,7 @@ export default defineConfig({ 'src/core/ShellRoutingSync.tsx', 'src/main.tsx', 'src/routes.tsx', + 'src/_mocks_', ], }, }, From a0243b9650f63ec78f4f6d5538c00dc574829785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:38:51 +0200 Subject: [PATCH 11/76] feat(pci-instance): switch empty components to hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../components/layout/Layout.component.tsx} | 29 ++++++++++--------- .../hooks/hidePreloader/useHidePreloader.ts} | 7 ++--- .../shellRoutingSync/useShellRoutingSync.ts} | 6 ++-- 3 files changed, 20 insertions(+), 22 deletions(-) rename packages/manager/apps/{pci-instance/src/pages/Layout.tsx => pci-instances/src/components/layout/Layout.component.tsx} (64%) rename packages/manager/apps/{pci-instance/src/components/hidePreloader/HidePreloader.tsx => pci-instances/src/hooks/hidePreloader/useHidePreloader.ts} (78%) rename packages/manager/apps/{pci-instance/src/components/shell/ShellRoutingSync.tsx => pci-instances/src/hooks/shellRoutingSync/useShellRoutingSync.ts} (86%) diff --git a/packages/manager/apps/pci-instance/src/pages/Layout.tsx b/packages/manager/apps/pci-instances/src/components/layout/Layout.component.tsx similarity index 64% rename from packages/manager/apps/pci-instance/src/pages/Layout.tsx rename to packages/manager/apps/pci-instances/src/components/layout/Layout.component.tsx index e59e72eb748c..495d26235942 100644 --- a/packages/manager/apps/pci-instance/src/pages/Layout.tsx +++ b/packages/manager/apps/pci-instances/src/components/layout/Layout.component.tsx @@ -1,21 +1,26 @@ import { Suspense, useContext, FC } from 'react'; import { useRouteError, Outlet } from 'react-router-dom'; -import { ErrorBanner } from '@ovhcloud/manager-components'; +import { ErrorBanner } from '@ovh-ux/manager-react-components'; import { ShellContext } from '@ovh-ux/manager-react-shell-client'; import { mapUnknownErrorToBannerError } from '@/utils'; -import HidePreloader from '@/components/hidePreloader/HidePreloader'; -import ShellRoutingSync from '@/components/shell/ShellRoutingSync'; +import { useHidePreloader } from '@/hooks/hidePreloader/useHidePreloader'; +import { useShellRoutingSync } from '@/hooks/shellRoutingSync/useShellRoutingSync'; -const Layout: FC = () => ( -
- - - - -
-); +const Layout: FC = () => { + useHidePreloader(); + useShellRoutingSync(); + return ( +
+ + + +
+ ); +}; export const ErrorBoundary = () => { + useHidePreloader(); + useShellRoutingSync(); const routerError = useRouteError(); const errorBannerError = mapUnknownErrorToBannerError(routerError); @@ -38,8 +43,6 @@ export const ErrorBoundary = () => { onRedirectHome={navigateToHomePage} error={errorBannerError} /> - -
); }; diff --git a/packages/manager/apps/pci-instance/src/components/hidePreloader/HidePreloader.tsx b/packages/manager/apps/pci-instances/src/hooks/hidePreloader/useHidePreloader.ts similarity index 78% rename from packages/manager/apps/pci-instance/src/components/hidePreloader/HidePreloader.tsx rename to packages/manager/apps/pci-instances/src/hooks/hidePreloader/useHidePreloader.ts index d1c2c6d1df85..2d4ea98ced72 100644 --- a/packages/manager/apps/pci-instance/src/components/hidePreloader/HidePreloader.tsx +++ b/packages/manager/apps/pci-instances/src/hooks/hidePreloader/useHidePreloader.ts @@ -1,12 +1,9 @@ import { ShellContext } from '@ovh-ux/manager-react-shell-client'; import { useContext, useEffect } from 'react'; -export default function HidePreloader() { +export const useHidePreloader = () => { const { ux } = useContext(ShellContext).shell; - useEffect(() => { ux.hidePreloader(); }, [ux]); - - return null; -} +}; diff --git a/packages/manager/apps/pci-instance/src/components/shell/ShellRoutingSync.tsx b/packages/manager/apps/pci-instances/src/hooks/shellRoutingSync/useShellRoutingSync.ts similarity index 86% rename from packages/manager/apps/pci-instance/src/components/shell/ShellRoutingSync.tsx rename to packages/manager/apps/pci-instances/src/hooks/shellRoutingSync/useShellRoutingSync.ts index 86e8afc52949..a0f74c085b7e 100644 --- a/packages/manager/apps/pci-instance/src/components/shell/ShellRoutingSync.tsx +++ b/packages/manager/apps/pci-instances/src/hooks/shellRoutingSync/useShellRoutingSync.ts @@ -2,7 +2,7 @@ import { ShellContext } from '@ovh-ux/manager-react-shell-client'; import { useContext, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -export default function ShellRoutingSync() { +export const useShellRoutingSync = () => { const location = useLocation(); const { routing } = useContext(ShellContext).shell; @@ -13,6 +13,4 @@ export default function ShellRoutingSync() { useEffect(() => { routing.onHashChange(); }, [location, routing]); - - return null; -} +}; From 9ed4b683ba80dc0109898855b61830bc7665f7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:40:54 +0200 Subject: [PATCH 12/76] feat(pci-instance): add new routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../apps/pci-instance/src/routes/routes.tsx | 21 -------- .../apps/pci-instances/src/routes/routes.tsx | 49 +++++++++++++++++++ 2 files changed, 49 insertions(+), 21 deletions(-) delete mode 100644 packages/manager/apps/pci-instance/src/routes/routes.tsx create mode 100644 packages/manager/apps/pci-instances/src/routes/routes.tsx diff --git a/packages/manager/apps/pci-instance/src/routes/routes.tsx b/packages/manager/apps/pci-instance/src/routes/routes.tsx deleted file mode 100644 index 6166eb82164b..000000000000 --- a/packages/manager/apps/pci-instance/src/routes/routes.tsx +++ /dev/null @@ -1,21 +0,0 @@ -const lazyRouteConfig = (importFn: CallableFunction) => ({ - lazy: async () => { - const { default: moduleDefault, ...moduleExports } = await importFn(); - - return { - Component: moduleDefault, - ...moduleExports, - }; - }, -}); - -export default [ - { - path: '/', - ...lazyRouteConfig(() => import('@/pages/Layout')), - }, - { - path: '*', - element:

Not found page

, - }, -]; diff --git a/packages/manager/apps/pci-instances/src/routes/routes.tsx b/packages/manager/apps/pci-instances/src/routes/routes.tsx new file mode 100644 index 000000000000..6eef34e6cf2a --- /dev/null +++ b/packages/manager/apps/pci-instances/src/routes/routes.tsx @@ -0,0 +1,49 @@ +import { RouteObject } from 'react-router-dom'; +import { getProjectQuery } from '@ovh-ux/manager-react-components'; +import queryClient from '@/queryClient'; + +const lazyRouteConfig = (importFn: CallableFunction) => ({ + lazy: async () => { + const { default: moduleDefault, ...moduleExports } = await importFn(); + + return { + Component: moduleDefault, + ...moduleExports, + }; + }, +}); + +export const ROOT_PATH = '/pci/projects/:projectId/instances'; +export const SUB_PATHS = { + onboarding: 'onboarding', +}; + +const routes: RouteObject[] = [ + { + path: '/', + ...lazyRouteConfig(() => import('@/components/layout/Layout.component')), + }, + { + path: ROOT_PATH, + id: 'root', + loader: async ({ params }) => + queryClient.fetchQuery(getProjectQuery(params.projectId ?? '')), + ...lazyRouteConfig(() => import('@/components/layout/Layout.component')), + children: [ + { + path: '', + ...lazyRouteConfig(() => import('@/pages/instances/Instances.page')), + }, + { + path: SUB_PATHS.onboarding, + ...lazyRouteConfig(() => import('@/pages/onboarding/Onboarding.page')), + }, + ], + }, + { + path: '*', + element:

Not found page

, + }, +]; + +export default routes; From b7901a87f5d56134c3dac0adafb6806ff9f3896c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:42:39 +0200 Subject: [PATCH 13/76] feat(pci-instance): add new components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../components/spinner/Spinner.component.tsx | 9 ++++++++ .../statusChip/StatusChip.component.tsx | 23 +++++++++++++++++++ .../src/pages/onboarding/Onboarding.page.tsx | 5 ++++ 3 files changed, 37 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/components/spinner/Spinner.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx diff --git a/packages/manager/apps/pci-instances/src/components/spinner/Spinner.component.tsx b/packages/manager/apps/pci-instances/src/components/spinner/Spinner.component.tsx new file mode 100644 index 000000000000..44536617d444 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/spinner/Spinner.component.tsx @@ -0,0 +1,9 @@ +import { ODS_SPINNER_SIZE } from '@ovhcloud/ods-components'; +import { OsdsSpinner } from '@ovhcloud/ods-components/react'; +import { FC } from 'react'; + +export const Spinner: FC = () => ( +
+ +
+); diff --git a/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx new file mode 100644 index 000000000000..378c12bd4381 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx @@ -0,0 +1,23 @@ +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { OsdsChip } from '@ovhcloud/ods-components/react'; +import { useTranslation } from 'react-i18next'; +import { InstanceStatus } from '@/data/hooks/instances/useInstances'; + +const StatusChip = ({ status }: { status: InstanceStatus }) => { + const { t } = useTranslation('status'); + + const colorBySeverityStatus = { + success: ODS_THEME_COLOR_INTENT.success, + error: ODS_THEME_COLOR_INTENT.error, + warning: ODS_THEME_COLOR_INTENT.warning, + info: ODS_THEME_COLOR_INTENT.info, + }; + + return ( + + {t(status.state)} + + ); +}; + +export default StatusChip; diff --git a/packages/manager/apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx b/packages/manager/apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx new file mode 100644 index 000000000000..c0753cebb637 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx @@ -0,0 +1,5 @@ +import { FC } from 'react'; + +const Onboarding: FC = () =>

Onboarding

; + +export default Onboarding; From cbcd038d77b777d0cc5853530bf93c02ba63d030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:44:17 +0200 Subject: [PATCH 14/76] test(pci-instance): update utils test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../src/utils/utils.spec.ts | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) rename packages/manager/apps/{pci-instance => pci-instances}/src/utils/utils.spec.ts (64%) diff --git a/packages/manager/apps/pci-instance/src/utils/utils.spec.ts b/packages/manager/apps/pci-instances/src/utils/utils.spec.ts similarity index 64% rename from packages/manager/apps/pci-instance/src/utils/utils.spec.ts rename to packages/manager/apps/pci-instances/src/utils/utils.spec.ts index 1b4875f93c03..4e392c4eae2b 100644 --- a/packages/manager/apps/pci-instance/src/utils/utils.spec.ts +++ b/packages/manager/apps/pci-instances/src/utils/utils.spec.ts @@ -1,8 +1,8 @@ import { AxiosError, AxiosHeaders } from 'axios'; import { describe, expect, test } from 'vitest'; -import { ErrorBannerProps } from '@ovhcloud/manager-components'; +import { ErrorBannerProps } from '@ovh-ux/manager-react-components'; import { ApiError } from '@ovh-ux/manager-core-api'; -import { mapUnknownErrorToBannerError } from './index'; +import { instancesQueryKey, mapUnknownErrorToBannerError } from './index'; describe('Utility functions', () => { describe('Considering the mapUnknownErrorToBannerError function', () => { @@ -93,4 +93,37 @@ describe('Utility functions', () => { }, ); }); + describe('Considering the instancesQueryKey function', () => { + type Data = { + projectId: string; + rest?: string[]; + expectedQueryKey?: string[]; + }; + + const fakeProjectId = '5a6980507a0a40dca362eb9b22d79049'; + const expectedQueryKey1 = ['project', fakeProjectId, 'instances']; + + const expectedRestParameters = ['test', 'id', '23']; + const expectedQueryKey2 = [...expectedQueryKey1, ...expectedRestParameters]; + + describe.each` + projectId | rest | expectedQueryKey + ${fakeProjectId} | ${[]} | ${expectedQueryKey1} + ${fakeProjectId} | ${[]} | ${expectedQueryKey1} + ${fakeProjectId} | ${expectedRestParameters} | ${expectedQueryKey2} + `( + 'Given a projectId <$projectId> and optional rest parameters <$rest>', + ({ projectId, rest, expectedQueryKey }: Data) => { + describe('When calling instancesQueryKey()', () => { + test(`Then, expect the output queryKey to be '${JSON.stringify( + expectedQueryKey, + )}'`, () => { + expect(instancesQueryKey(projectId, rest)).toStrictEqual( + expectedQueryKey, + ); + }); + }); + }, + ); + }); }); From c0f060733b376be53e1682d5ab1adac243670ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:45:37 +0200 Subject: [PATCH 15/76] feat(pci-instance): add useInstances hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../src/data/hooks/instances/useInstances.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts new file mode 100644 index 000000000000..5f3cd3964c1e --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts @@ -0,0 +1,216 @@ +import { ApiError } from '@ovh-ux/manager-core-api'; +import { + InfiniteData, + keepPreviousData, + useInfiniteQuery, + UseInfiniteQueryResult, + useQueryClient, +} from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo } from 'react'; +import { FilterWithLabel } from '@ovh-ux/manager-react-components/src/components/filters/interface'; +import { + InstanceDto, + retrieveInstances, + InstanceStatusDto, +} from '@/data/api/instances'; +import { instancesQueryKey } from '@/utils'; +import { DeepReadonly } from '@/types/utils.type'; + +type UseInstancesResult = UseInfiniteQueryResult< + Instance[] | undefined, + ApiError +> & { + hasInconsistency: boolean; +}; + +export type UseInstances = ( + projectId: string, + params: UseInstancesQueryParams, +) => UseInstancesResult; + +export type UseInstancesQueryParams = DeepReadonly<{ + limit: number; + sort: string; + sortOrder: 'asc' | 'desc'; + filters: FilterWithLabel[]; +}>; + +export type InstanceId = string; +export type FlavorId = string; +export type ImageId = string; +export type AddressType = 'public' | 'private'; + +export type InstanceStatusSeverity = 'success' | 'error' | 'warning' | 'info'; +export type InstanceStatusState = InstanceStatusDto; +export type InstanceStatus = { + state: InstanceStatusState; + severity: InstanceStatusSeverity; +}; + +export type Address = { + ip: string; + version: number; + gatewayIp: string; +}; + +export type Instance = DeepReadonly<{ + id: InstanceId; + name: string; + flavorId: FlavorId; + flavorName: string; + status: InstanceStatus; + region: string; + imageId: ImageId; + imageName: string; + addresses: Map; +}>; + +const buildInstanceStatusSeverity = ( + status: InstanceStatusDto, +): InstanceStatusSeverity => { + switch (status) { + case 'BUILDING': + case 'REBOOT': + case 'REBUILD': + case 'REVERT_RESIZE': + case 'SOFT_DELETED': + case 'VERIFY_RESIZE': + case 'MIGRATING': + case 'RESIZE': + case 'BUILD': + case 'SHUTOFF': + case 'RESCUE': + case 'SHELVED': + case 'SHELVED_OFFLOADED': + case 'RESCUING': + case 'UNRESCUING': + case 'SNAPSHOTTING': + case 'RESUMING': + case 'HARD_REBOOT': + case 'PASSWORD': + case 'PAUSED': + return 'warning'; + case 'DELETED': + case 'ERROR': + case 'STOPPED': + case 'SUSPENDED': + case 'UNKNOWN': + return 'error'; + case 'ACTIVE': + case 'RESCUED': + case 'RESIZED': + return 'success'; + default: + return 'info'; + } +}; +const getInstanceStatus = (status: InstanceStatusDto): InstanceStatus => ({ + state: status, + severity: buildInstanceStatusSeverity(status), +}); + +const getInconsistency = (data: Instance[] | undefined): boolean => + !!data?.some((elt) => elt.status.state === 'UNKNOWN'); + +export const instancesSelector = ( + { pages }: InfiniteData, + limit: number, +): Instance[] => + pages + .flatMap((page) => (page.length > limit ? page.slice(0, limit) : page)) + .map((instanceDto) => ({ + ...instanceDto, + status: getInstanceStatus(instanceDto.status), + addresses: instanceDto.addresses.reduce((acc, { type, ...rest }) => { + const foundAddresses = acc.get(type); + const ipAlreadyExists = !!foundAddresses?.find( + ({ ip }) => ip === rest.ip, + ); + if (foundAddresses) { + if (ipAlreadyExists) return acc.set(type, [...foundAddresses]); + return acc.set(type, [...foundAddresses, rest]); + } + return acc.set(type, [rest]); + }, new Map()), + })); + +export const useInstances = ( + projectId: string, + { limit, sort, sortOrder, filters }: UseInstancesQueryParams, +) => { + const queryClient = useQueryClient(); + const filtersQueryKey = useMemo( + () => + filters?.length > 0 + ? [ + 'filter', + filters[0].label, + filters[0].comparator, + filters[0].value as string, + ] + : [], + [filters], + ); + const queryKey = useMemo( + () => + instancesQueryKey(projectId, [ + 'list', + 'sort', + sort, + sortOrder, + ...filtersQueryKey, + ]), + [filtersQueryKey, projectId, sort, sortOrder], + ); + + const refresh = useCallback(() => { + queryClient.removeQueries({ + predicate: (query) => + query.queryKey.includes('list') && + query.queryKey !== + instancesQueryKey(projectId, ['list', 'sort', 'name', 'asc']), + }); + }, [projectId, queryClient]); + + useEffect(() => { + const queryData = queryClient.getQueryData>( + queryKey, + ); + if (queryData?.pageParams && queryData.pageParams.length > 1) { + queryClient.resetQueries({ + queryKey, + exact: true, + }); + } + }, [projectId, sort, sortOrder, queryClient, queryKey, filters.length]); + + const { data, ...rest } = useInfiniteQuery({ + queryKey, + retry: false, + initialPageParam: 0, + getNextPageParam: (lastPage, _allPages, lastPageParam) => + lastPage.length > limit ? lastPageParam + 1 : null, + queryFn: ({ pageParam }) => + retrieveInstances(projectId, { + limit, + sort, + sortOrder, + offset: pageParam * limit, + ...(filters.length > 0 && { searchField: filters[0].label }), + ...(filters.length > 0 && { searchValue: filters[0].value as string }), + }), + select: useCallback( + (rawData: InfiniteData) => + instancesSelector(rawData, limit), + [limit], + ), + placeholderData: keepPreviousData, + }); + + return { + data, + hasInconsistency: getInconsistency(data), + refresh, + ...rest, + }; +}; From e92fe7416c47c46e10ef09d45ca447ba5a61d28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:47:12 +0200 Subject: [PATCH 16/76] test(pci-instance): add useInstances test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../src/_mocks_/instances/handlers.ts | 11 + .../src/_mocks_/instances/node.ts | 13 + .../hooks/instances/useInstances.spec.tsx | 280 ++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/_mocks_/instances/handlers.ts create mode 100644 packages/manager/apps/pci-instances/src/_mocks_/instances/node.ts create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx diff --git a/packages/manager/apps/pci-instances/src/_mocks_/instances/handlers.ts b/packages/manager/apps/pci-instances/src/_mocks_/instances/handlers.ts new file mode 100644 index 000000000000..5a76f92ef5c2 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/_mocks_/instances/handlers.ts @@ -0,0 +1,11 @@ +import { http, HttpResponse, JsonBodyType, RequestHandler } from 'msw'; + +export const instancesHandlers = ( + mockedResponsePayload?: T, +): RequestHandler[] => [ + http.get('*/cloud/project/:projectId/aggregated/instance', async () => { + return !mockedResponsePayload + ? new HttpResponse(null, { status: 500 }) + : HttpResponse.json(mockedResponsePayload); + }), +]; diff --git a/packages/manager/apps/pci-instances/src/_mocks_/instances/node.ts b/packages/manager/apps/pci-instances/src/_mocks_/instances/node.ts new file mode 100644 index 000000000000..2a54c0e13045 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/_mocks_/instances/node.ts @@ -0,0 +1,13 @@ +import { JsonBodyType } from 'msw'; +import { setupServer } from 'msw/node'; +import { instancesHandlers } from './handlers'; + +export const setupInstanceServer = ( + mockedResponsePayload?: T, +) => { + const server = setupServer(...instancesHandlers(mockedResponsePayload)); + server.listen({ + onUnhandledRequest: 'error', + }); + return server; +}; diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx new file mode 100644 index 000000000000..0839a8801016 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx @@ -0,0 +1,280 @@ +import { AxiosError } from 'axios'; +import { SetupServer } from 'msw/node'; +import { FC, PropsWithChildren } from 'react'; +import { describe, expect, test, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { FilterComparator } from '@ovh-ux/manager-core-api'; +import { + Instance, + InstanceStatus, + useInstances, + UseInstancesQueryParams, +} from './useInstances'; +import { InstanceDto, InstanceStatusDto } from '@/data/api/instances'; +import { setupInstanceServer } from '@/_mocks_/instances/node'; + +// builders +const instanceDtoBuilder = ( + addresses: InstanceDto['addresses'], + status: InstanceStatusDto, +): InstanceDto => ({ + id: `fake-id`, + name: `fake-instance-name`, + flavorId: `fake-flavor-id`, + flavorName: `fake-flavor-name`, + imageId: `fake-image-id`, + imageName: `fake-image-name`, + region: `fake-region`, + status, + addresses, +}); + +const instanceBuilder = ( + instanceDto: InstanceDto, + addresses: Instance['addresses'], + status: InstanceStatus, +): Instance => ({ + ...instanceDto, + addresses, + status, +}); + +// initializers +const initQueryClient = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const wrapper: FC = ({ children }) => ( + {children} + ); + return { wrapper, queryClient }; +}; + +// test data +type Data = { + projectId: string; + queryParamaters: UseInstancesQueryParams; + queryPayload?: InstanceDto[]; + expectedInstances: Instance[]; + expectedQueryHasNext: boolean; + expectedInstancesAfterRefetch: Instance[]; + expectedQueryKey: string[]; +}; + +const fakeProjectId = 'p42b4f068f404ef3832435304a316332'; +const fakeQueryParamaters1: UseInstancesQueryParams = { + limit: 10, + sort: 'name', + sortOrder: 'asc', + filters: [], +}; +const fakeQueryParamaters2: UseInstancesQueryParams = { + limit: 1, + sort: 'name', + sortOrder: 'asc', + filters: [], +}; +const fakeQueryParamaters3: UseInstancesQueryParams = { + limit: 10, + sort: 'image', + sortOrder: 'desc', + filters: [], +}; +const fakeQueryParamaters4: UseInstancesQueryParams = { + limit: 10, + sort: 'flavor', + sortOrder: 'asc', + filters: [ + { + key: 'search', + label: 'flavor', + comparator: FilterComparator.IsEqual, + value: 'foo', + }, + ], +}; + +const fakeInstanceDto1: InstanceDto = instanceDtoBuilder([], 'ACTIVE'); +const fakeInstance1: Instance = instanceBuilder(fakeInstanceDto1, new Map(), { + state: 'ACTIVE', + severity: 'success', +}); + +const fakeInstanceDto2: InstanceDto = instanceDtoBuilder( + [{ type: 'private', ip: '192.00.123.34', gatewayIp: '', version: 1 }], + 'ERROR', +); +const fakeInstance2: Instance = instanceBuilder( + fakeInstanceDto2, + new Map().set('private', [ + { + ip: '192.00.123.34', + gatewayIp: '', + version: 1, + }, + ]), + { state: 'ERROR', severity: 'error' }, +); + +const fakeInstanceDto3: InstanceDto = instanceDtoBuilder( + [ + { type: 'private', ip: '192.00.123.34', gatewayIp: '', version: 1 }, + { type: 'public', ip: '193.02.689.00', gatewayIp: '', version: 2 }, + { type: 'public', ip: '191.01.045.10', gatewayIp: '', version: 7 }, + ], + 'DELETING', +); +const fakeInstance3: Instance = instanceBuilder( + fakeInstanceDto3, + new Map() + .set('private', [ + { + ip: '192.00.123.34', + gatewayIp: '', + version: 1, + }, + ]) + .set('public', [ + { ip: '193.02.689.00', gatewayIp: '', version: 2 }, + { ip: '191.01.045.10', gatewayIp: '', version: 7 }, + ]), + { state: 'DELETING', severity: 'info' }, +); + +const fakeInstanceDto4: InstanceDto = instanceDtoBuilder( + [ + { type: 'public', ip: '193.02.689.00', gatewayIp: '', version: 2 }, + { type: 'public', ip: '193.02.689.00', gatewayIp: '', version: 7 }, + ], + 'BUILD', +); +const fakeInstance4: Instance = instanceBuilder( + fakeInstanceDto4, + new Map().set('public', [ + { + ip: '193.02.689.00', + gatewayIp: '', + version: 2, + }, + ]), + { state: 'BUILD', severity: 'warning' }, +); + +const fakeQueryKey1 = [ + 'project', + 'p42b4f068f404ef3832435304a316332', + 'instances', + 'list', + 'sort', + 'name', + 'asc', +]; + +const fakeQueryKey2 = [ + 'project', + 'p42b4f068f404ef3832435304a316332', + 'instances', + 'list', + 'sort', + 'image', + 'desc', +]; + +const fakeQueryKey3 = [ + 'project', + 'p42b4f068f404ef3832435304a316332', + 'instances', + 'list', + 'sort', + 'flavor', + 'asc', + 'filter', + 'flavor', + 'is_equal', + 'foo', +]; + +// msw server +let server: SetupServer; + +describe('UseInstances hook', () => { + describe.each` + projectId | queryParamaters | queryPayload | expectedInstances | expectedQueryHasNext | expectedInstancesAfterRefetch | expectedQueryKey + ${fakeProjectId} | ${fakeQueryParamaters1} | ${[]} | ${[]} | ${false} | ${undefined} | ${fakeQueryKey1} + ${fakeProjectId} | ${fakeQueryParamaters1} | ${[fakeInstanceDto1]} | ${[fakeInstance1]} | ${false} | ${undefined} | ${fakeQueryKey1} + ${fakeProjectId} | ${fakeQueryParamaters3} | ${[fakeInstanceDto1]} | ${[fakeInstance1]} | ${false} | ${undefined} | ${fakeQueryKey2} + ${fakeProjectId} | ${fakeQueryParamaters4} | ${[fakeInstanceDto1]} | ${[fakeInstance1]} | ${false} | ${undefined} | ${fakeQueryKey3} + ${fakeProjectId} | ${fakeQueryParamaters1} | ${[fakeInstanceDto1, fakeInstanceDto2]} | ${[fakeInstance1, fakeInstance2]} | ${false} | ${undefined} | ${fakeQueryKey1} + ${fakeProjectId} | ${fakeQueryParamaters2} | ${[fakeInstanceDto1, fakeInstanceDto2]} | ${[fakeInstance1]} | ${true} | ${[fakeInstance1, fakeInstance1]} | ${fakeQueryKey1} + ${fakeProjectId} | ${fakeQueryParamaters1} | ${[fakeInstanceDto2]} | ${[fakeInstance2]} | ${false} | ${undefined} | ${fakeQueryKey1} + ${fakeProjectId} | ${fakeQueryParamaters1} | ${[fakeInstanceDto3]} | ${[fakeInstance3]} | ${false} | ${undefined} | ${fakeQueryKey1} + ${fakeProjectId} | ${fakeQueryParamaters1} | ${[fakeInstanceDto4]} | ${[fakeInstance4]} | ${false} | ${undefined} | ${fakeQueryKey1} + ${fakeProjectId} | ${fakeQueryParamaters1} | ${undefined} | ${undefined} | ${false} | ${undefined} | ${fakeQueryKey1} + `( + 'Given a projectId <$projectId> and query parameters <$queryParamaters>', + ({ + projectId, + queryParamaters, + queryPayload, + expectedInstances, + expectedQueryHasNext, + expectedInstancesAfterRefetch, + expectedQueryKey, + }: Data) => { + describe('useInstances() hook', () => { + afterEach(() => { + server?.close(); + }); + test(`When invoking useInstances() hook', then, expect the computed instances to be '${JSON.stringify( + expectedInstances, + )}' and the query hasNext property to be ${expectedQueryHasNext}`, async () => { + server = setupInstanceServer(queryPayload); + + const { wrapper, queryClient } = initQueryClient(); + const { result } = renderHook( + () => useInstances(projectId, queryParamaters), + { + wrapper, + }, + ); + + expect(result.current.isPending).toBe(true); + + if (queryPayload) { + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + } else { + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toHaveProperty('response.status', 500); + expect(result.current.error).instanceOf(AxiosError); + } + + const queryCache = queryClient.getQueryCache(); + expect(result.current.hasNextPage).toBe(expectedQueryHasNext); + expect(result.current.data).toStrictEqual(expectedInstances); + + if (result.current.hasNextPage) { + await act(() => result.current.fetchNextPage()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toStrictEqual( + expectedInstancesAfterRefetch, + ); + } + + expect( + queryCache.getAll().map((cache) => cache.queryKey)[0], + ).toStrictEqual(expectedQueryKey); + + act(() => result.current.refresh()); + expect( + queryCache.getAll().map((cache) => cache.queryKey).length, + ).toStrictEqual(0); + }); + }); + }, + ); +}); From 4352fde26978d48c65e431317ea14f4e6c2b1bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:48:51 +0200 Subject: [PATCH 17/76] feat(pci-instance): add Instances page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../pci-instances/src/data/api/instances.ts | 92 ++++ .../src/pages/instances/Instances.page.tsx | 432 ++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/data/api/instances.ts create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx diff --git a/packages/manager/apps/pci-instances/src/data/api/instances.ts b/packages/manager/apps/pci-instances/src/data/api/instances.ts new file mode 100644 index 000000000000..3ac8fbeb61f7 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/api/instances.ts @@ -0,0 +1,92 @@ +import { v6 } from '@ovh-ux/manager-core-api'; +import { DeepReadonly } from '@/types/utils.type'; + +type InstanceDtoId = string; +type FlavorDtoId = string; +type ImageDtoId = string; +type InstanceDtoAddressType = 'public' | 'private'; + +type InstanceDtoAddress = { + ip: string; + version: number; + type: InstanceDtoAddressType; + gatewayIp: string; +}; + +export type InstanceStatusDto = + | 'ACTIVE' + | 'BUILDING' + | 'DELETED' + | 'DELETING' + | 'ERROR' + | 'HARD_REBOOT' + | 'PASSWORD' + | 'PAUSED' + | 'REBOOT' + | 'REBUILD' + | 'RESCUED' + | 'RESIZED' + | 'REVERT_RESIZE' + | 'SOFT_DELETED' + | 'STOPPED' + | 'SUSPENDED' + | 'UNKNOWN' + | 'VERIFY_RESIZE' + | 'MIGRATING' + | 'RESIZE' + | 'BUILD' + | 'SHUTOFF' + | 'RESCUE' + | 'SHELVED' + | 'SHELVING' + | 'UNSHELVING' + | 'SHELVED_OFFLOADED' + | 'RESCUING' + | 'UNRESCUING' + | 'SNAPSHOTTING' + | 'RESUMING'; + +export type InstanceDto = DeepReadonly<{ + addresses: InstanceDtoAddress[]; + flavorId: FlavorDtoId; + flavorName: string; + id: InstanceDtoId; + imageId: ImageDtoId; + imageName: string; + name: string; + region: string; + status: InstanceStatusDto; +}>; + +export type RetrieveInstancesQueryParams = { + limit: number; + sort: string; + sortOrder: 'asc' | 'desc'; + offset?: number; + searchField?: string; + searchValue?: string; +}; + +export const retrieveInstances = ( + projectId: string, + { + limit, + sort, + sortOrder, + offset, + searchField, + searchValue, + }: RetrieveInstancesQueryParams, +): Promise => + v6 + .get(`/cloud/project/${projectId}/aggregated/instance`, { + params: { + limit: limit + 1, + sort, + sortOrder, + offset, + searchField, + searchValue, + }, + }) + .then((response) => response.data); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx new file mode 100644 index 000000000000..43c70f739843 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -0,0 +1,432 @@ +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams, useRouteLoaderData } from 'react-router-dom'; +import { + Datagrid, + DatagridColumn, + DataGridTextCell, + FilterAdd, + FilterList, + Notifications, + PciGuidesHeader, + PublicCloudProject, + Title, + useColumnFilters, + useNotifications, +} from '@ovh-ux/manager-react-components'; +import { + ODS_THEME_COLOR_INTENT, + ODS_THEME_TYPOGRAPHY_LEVEL, + ODS_THEME_TYPOGRAPHY_SIZE, +} from '@ovhcloud/ods-common-theming'; +import { + OsdsBreadcrumb, + OsdsButton, + OsdsDivider, + OsdsIcon, + OsdsPopover, + OsdsPopoverContent, + OsdsSearchBar, + OsdsText, + OsdsSkeleton, + OsdsLink, +} from '@ovhcloud/ods-components/react'; +import { + ODS_BUTTON_SIZE, + ODS_BUTTON_VARIANT, + ODS_ICON_NAME, + ODS_ICON_SIZE, + OsdsSearchBarCustomEvent, +} from '@ovhcloud/ods-components'; +import { Trans, useTranslation } from 'react-i18next'; +import { FilterComparator } from '@ovh-ux/manager-core-api'; +import { Spinner } from '@/components/spinner/Spinner.component'; +import { Instance, useInstances } from '@/data/hooks/instances/useInstances'; +import StatusChip from '@/components/statusChip/StatusChip.component'; + +const initialSort = { + id: 'name', + desc: false, +}; + +const Instances: FC = () => { + const { t } = useTranslation('list'); + const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader + const project = useRouteLoaderData('root') as PublicCloudProject; + const navigate = useNavigate(); + const [sorting, setSorting] = useState(initialSort); + const [searchField, setSearchField] = useState(''); + const { filters, addFilter, removeFilter } = useColumnFilters(); + const { + addWarning, + clearNotifications, + notifications, + addError, + } = useNotifications(); + const filterPopoverRef = useRef(null); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + hasInconsistency, + refresh, + isFetching, + isRefetching, + isError, + } = useInstances(projectId, { + limit: 25, + sort: sorting.id, + sortOrder: sorting.desc ? 'desc' : 'asc', + filters, + }); + + const datagridColumns: DatagridColumn[] = useMemo( + () => [ + { + id: 'name', + cell: (props: Instance) => + isRefetching ? ( + + ) : ( + <> + + {props.name} + + + {props.id} + + + ), + label: t('nameId'), + isSortable: true, + }, + { + id: 'region', + cell: (props: Instance) => + isRefetching ? ( + + ) : ( + {props.region} + ), + label: t('region'), + isSortable: false, + }, + { + id: 'flavor', + cell: (props: Instance) => + isRefetching ? ( + + ) : ( + {props.flavorName} + ), + label: t('flavor'), + isSortable: true, + }, + { + id: 'image', + cell: (props: Instance) => + isRefetching ? ( + + ) : ( + {props.imageName} + ), + label: t('image'), + isSortable: true, + }, + { + id: 'publicIPs', + cell: (props: Instance) => + isRefetching ? ( + + ) : ( + +
    + {props.addresses.get('public')?.map((item) => ( +
  • + {item.ip} +
  • + ))} +
+
+ ), + label: t('publicIPs'), + isSortable: false, + }, + { + id: 'privateIPs', + cell: (props: Instance) => + isRefetching ? ( + + ) : ( + +
    + {props.addresses.get('private')?.map((item) => ( +
  • + {item.ip} +
  • + ))} +
+
+ ), + label: t('privateIPs'), + isSortable: false, + }, + { + id: 'status', + cell: (props: Instance) => + isRefetching ? ( + + ) : ( + + ), + label: t('status'), + isSortable: false, + }, + ], + [isRefetching, t], + ); + + const filterColumns = useMemo( + () => [ + { + id: 'name', + label: t('nameId'), + comparators: [FilterComparator.Includes], + }, + { + id: 'flavor', + label: t('flavor'), + comparators: [FilterComparator.Includes], + }, + { + id: 'image', + label: t('image'), + comparators: [FilterComparator.Includes], + }, + ], + [t], + ); + + const resetSortAndFilters = useCallback(() => { + setSorting(initialSort); + filters.forEach(removeFilter); + }, [filters]); + + const handleRefresh = useCallback(() => { + refresh(); + resetSortAndFilters(); + }, [refresh, resetSortAndFilters]); + + const errorMessage = useMemo( + () => ( + <> + + ), + }} + /> +
+ + + ), + [handleRefresh, t], + ); + + const handleOdsSearchSubmit = useCallback( + ( + event: OsdsSearchBarCustomEvent<{ + optionValue: string; + inputValue: string; + }>, + ) => { + addFilter({ + key: 'search', + value: event.detail.inputValue, + comparator: FilterComparator.Includes, + label: 'name', + }); + setSearchField(''); + }, + [addFilter], + ); + + const handleFetchNextPage = useCallback(() => { + fetchNextPage(); + }, [fetchNextPage]); + + useEffect(() => { + if (data && !filters.length && !data.length && !isFetching) + navigate(`/pci/projects/${projectId}/instances/onboarding`); + }, [data, filters.length, isFetching, navigate, projectId]); + + useEffect(() => { + if (hasInconsistency) addWarning(t('inconsistencyMessage'), true); + return () => { + clearNotifications(); + }; + }, [addWarning, hasInconsistency, t, clearNotifications]); + + useEffect(() => { + if (isFetching && notifications.length) clearNotifications(); + }, [clearNotifications, isFetching, notifications.length]); + + useEffect(() => { + if (isError) addError(errorMessage, true); + }, [isError, addError, t, errorMessage]); + + if (isLoading) return ; + + return ( + <> + {project && ( + + )} +
+
+ {t('instancesTitle')} + +
+
+
+ + + +
+ + + + {t('createInstance')} + + +
+
+ + + +
+ 0 || isFetching} + onOdsSearchSubmit={handleOdsSearchSubmit} + /> + + 0 || isFetching) && { disabled: true })} + > + + {t('filter')} + + + { + addFilter({ + ...addedFilter, + label: column.id, + }); + filterPopoverRef.current?.closeSurface(); + }} + /> + + +
+
+
+ +
+ {data && ( +
+ +
+ )} + {isFetchingNextPage && ( +
+ +
+ )} +
+ + ); +}; + +export default Instances; From 179bfe69e2e77130c37633ad258b1713dcadab29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:50:21 +0200 Subject: [PATCH 18/76] feat(pci-instance): add translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../translations/list/Messages_fr_FR.json | 18 +++++++++ .../translations/status/Messages_fr_FR.json | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json create mode 100644 packages/manager/apps/pci-instances/public/translations/status/Messages_fr_FR.json diff --git a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json new file mode 100644 index 000000000000..4fa0156036a0 --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json @@ -0,0 +1,18 @@ +{ + "title": "Listing page", + "nameId": "Nom/ID", + "status": "Statut", + "publicIPs": "IP publique(s)", + "privateIPs": "IP privée(s)", + "cancel": "Annuler", + "image": "Image", + "flavor": "Modèle", + "instancesTitle": "Instances", + "createInstance": "Créer une instance", + "region": "Localisation", + "filter": "Filtrer", + "refresh": "Rafraîchir", + "inconsistencyMessage": "Toutes les données n'ont pas pu être chargées. Notre équipe technique est informée et travaille à résoudre le problème.", + "unknownErrorMessage1": "Nous n'avons pas pu récupérer la liste de vos instances. Veuillez cliquer sur le bouton 'Rafraîchir' pour essayer à nouveau.", + "unknownErrorMessage2": " Nos équipes techniques ont été notifiées de ce problème et travaillent à sa résolution." +} diff --git a/packages/manager/apps/pci-instances/public/translations/status/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/status/Messages_fr_FR.json new file mode 100644 index 000000000000..9c6aef7679b9 --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/status/Messages_fr_FR.json @@ -0,0 +1,38 @@ +{ + "ACTIVE": "Activée", + "BUILDING": "Création", + "DELETED": "Supprimée", + "DELETING": "Suppression", + "ERROR": "En erreur", + "HARD_REBOOT": "Redémarrage (hard)", + "PASSWORD": "Mot de passe", + "PAUSED": "En pause", + "PAUSED_TOOLTIP": "Vous serez toujours facturé au même prix pour votre instance.", + "REBOOT": "Redémarrage", + "REBUILD": "Réinstallation", + "RESCUED": "En mode rescue", + "RESIZED": "Mise à jour", + "REVERT_RESIZE": "Annulation", + "SOFT_DELETED": "Supprimée (soft)", + "STOPPED": "Arretée", + "SUSPENDED": "Suspendue", + "SUSPENDED_TOOLTIP": "Vous serez toujours facturé au même prix pour votre instance.", + "UNKNOWN": "Indéfini", + "VERIFY_RESIZE": "Mise à jour en cours", + "MIGRATING": "Migration en cours", + "RESIZE": "Mise à jour en cours", + "BUILD": "Création", + "SHUTOFF": "Éteinte", + "SHUTOFF_TOOLTIP": "Vous serez toujours facturé au même prix pour votre instance.", + "RESCUE": "Rescue", + "SHELVED": "Suspendue", + "SHELVED_TOOLTIP": "Seul le snapshot est facturé.", + "SHELVING": "En cours de suspension", + "UNSHELVING": "En cours de réactivation", + "SHELVED_OFFLOADED": "Suspendue", + "SHELVED_OFFLOADED_TOOLTIP": "Seul le snapshot est facturé.", + "RESCUING": "Redémarrage (rescue)", + "UNRESCUING": "Redémarrage", + "SNAPSHOTTING": "Création d'une instance backup", + "RESUMING": "Reprise en cours" +} From d8df0d992970cf0c0f151ed7a3657decdaa87fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 12 Sep 2024 18:53:10 +0200 Subject: [PATCH 19/76] feat(pci-instance): add deepReadonly types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../pci-instances/src/data/api/instances.ts | 4 +-- .../pci-instances/src/types/utils.type.ts | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/types/utils.type.ts diff --git a/packages/manager/apps/pci-instances/src/data/api/instances.ts b/packages/manager/apps/pci-instances/src/data/api/instances.ts index 3ac8fbeb61f7..ef821d83e7a2 100644 --- a/packages/manager/apps/pci-instances/src/data/api/instances.ts +++ b/packages/manager/apps/pci-instances/src/data/api/instances.ts @@ -58,14 +58,14 @@ export type InstanceDto = DeepReadonly<{ status: InstanceStatusDto; }>; -export type RetrieveInstancesQueryParams = { +export type RetrieveInstancesQueryParams = DeepReadonly<{ limit: number; sort: string; sortOrder: 'asc' | 'desc'; offset?: number; searchField?: string; searchValue?: string; -}; +}>; export const retrieveInstances = ( projectId: string, diff --git a/packages/manager/apps/pci-instances/src/types/utils.type.ts b/packages/manager/apps/pci-instances/src/types/utils.type.ts new file mode 100644 index 000000000000..8923e12a73c2 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/types/utils.type.ts @@ -0,0 +1,29 @@ +export type Primitive = + | undefined + | null + | boolean + | string + | number + | (() => any); +export type DeepReadonlyMap = ReadonlyMap< + DeepReadonly, + DeepReadonly +>; +export type DeepReadonlyObject = { + readonly [K in keyof T]: DeepReadonly; +}; +export type DeepReadonlyArray = ReadonlyArray>; +export type DeepReadonlySet = ReadonlySet>; + +// Ensure readonly recursivity that is not handled by TS engine +export type DeepReadonly = T extends Primitive + ? T + : T extends Map + ? DeepReadonlyMap + : T extends Record + ? DeepReadonlyObject + : T extends Array + ? DeepReadonlyArray + : T extends Set + ? DeepReadonlySet + : unknown; From e0dc01d760927c8ffc77208e34d4a0444dc0cc2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 13 Sep 2024 12:33:14 +0200 Subject: [PATCH 20/76] feat(pci-instance): makes sonar happy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../translations/list/Messages_fr_FR.json | 15 ++- .../translations/status/Messages_fr_FR.json | 67 +++++----- .../statusChip/StatusChip.component.tsx | 20 +-- .../pci-instances/src/data/api/instances.ts | 31 +++-- .../hooks/instances/useInstances.spec.tsx | 56 ++++----- .../src/data/hooks/instances/useInstances.ts | 73 +++++------ .../src/pages/instances/Instances.page.tsx | 115 ++++++++---------- .../pci-instances/src/types/utils.type.ts | 2 +- 8 files changed, 168 insertions(+), 211 deletions(-) diff --git a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json index 4fa0156036a0..1b97f4cc8f61 100644 --- a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json @@ -1,18 +1,17 @@ { - "title": "Listing page", "nameId": "Nom/ID", "status": "Statut", - "publicIPs": "IP publique(s)", - "privateIPs": "IP privée(s)", + "public_IPs": "IP publique(s)", + "private_IPs": "IP privée(s)", "cancel": "Annuler", "image": "Image", "flavor": "Modèle", - "instancesTitle": "Instances", - "createInstance": "Créer une instance", + "instances_title": "Instances", + "create_instance": "Créer une instance", "region": "Localisation", "filter": "Filtrer", "refresh": "Rafraîchir", - "inconsistencyMessage": "Toutes les données n'ont pas pu être chargées. Notre équipe technique est informée et travaille à résoudre le problème.", - "unknownErrorMessage1": "Nous n'avons pas pu récupérer la liste de vos instances. Veuillez cliquer sur le bouton 'Rafraîchir' pour essayer à nouveau.", - "unknownErrorMessage2": " Nos équipes techniques ont été notifiées de ce problème et travaillent à sa résolution." + "inconsistency_message": "Toutes les données n'ont pas pu être chargées. Notre équipe technique est informée et travaille à résoudre le problème.", + "unknown_error_message1": "Nous n'avons pas pu récupérer la liste de vos instances. Veuillez cliquer sur le bouton 'Rafraîchir' pour essayer à nouveau.", + "unknown_error_message2": "Nos équipes techniques ont été notifiées de ce problème et travaillent à sa résolution." } diff --git a/packages/manager/apps/pci-instances/public/translations/status/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/status/Messages_fr_FR.json index 9c6aef7679b9..6801869f33e8 100644 --- a/packages/manager/apps/pci-instances/public/translations/status/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/status/Messages_fr_FR.json @@ -1,38 +1,33 @@ { - "ACTIVE": "Activée", - "BUILDING": "Création", - "DELETED": "Supprimée", - "DELETING": "Suppression", - "ERROR": "En erreur", - "HARD_REBOOT": "Redémarrage (hard)", - "PASSWORD": "Mot de passe", - "PAUSED": "En pause", - "PAUSED_TOOLTIP": "Vous serez toujours facturé au même prix pour votre instance.", - "REBOOT": "Redémarrage", - "REBUILD": "Réinstallation", - "RESCUED": "En mode rescue", - "RESIZED": "Mise à jour", - "REVERT_RESIZE": "Annulation", - "SOFT_DELETED": "Supprimée (soft)", - "STOPPED": "Arretée", - "SUSPENDED": "Suspendue", - "SUSPENDED_TOOLTIP": "Vous serez toujours facturé au même prix pour votre instance.", - "UNKNOWN": "Indéfini", - "VERIFY_RESIZE": "Mise à jour en cours", - "MIGRATING": "Migration en cours", - "RESIZE": "Mise à jour en cours", - "BUILD": "Création", - "SHUTOFF": "Éteinte", - "SHUTOFF_TOOLTIP": "Vous serez toujours facturé au même prix pour votre instance.", - "RESCUE": "Rescue", - "SHELVED": "Suspendue", - "SHELVED_TOOLTIP": "Seul le snapshot est facturé.", - "SHELVING": "En cours de suspension", - "UNSHELVING": "En cours de réactivation", - "SHELVED_OFFLOADED": "Suspendue", - "SHELVED_OFFLOADED_TOOLTIP": "Seul le snapshot est facturé.", - "RESCUING": "Redémarrage (rescue)", - "UNRESCUING": "Redémarrage", - "SNAPSHOTTING": "Création d'une instance backup", - "RESUMING": "Reprise en cours" + "active": "Activée", + "building": "Création", + "deleted": "Supprimée", + "deleting": "Suppression", + "error": "En erreur", + "hard_reboot": "Redémarrage (hard)", + "password": "Mot de passe", + "paused": "En pause", + "reboot": "Redémarrage", + "rebuild": "Réinstallation", + "rescued": "En mode rescue", + "resized": "Mise à jour", + "revert_resize": "Annulation", + "soft_deleted": "Supprimée (soft)", + "stopped": "Arretée", + "suspended": "Suspendue", + "unknown": "Indéfini", + "verify_resize": "Mise à jour en cours", + "migrating": "Migration en cours", + "resize": "Mise à jour en cours", + "build": "Création", + "shutoff": "Éteinte", + "rescue": "Rescue", + "shelved": "Suspendue", + "shelving": "En cours de suspension", + "unshelving": "En cours de réactivation", + "shelved_offloaded": "Suspendue", + "rescuing": "Redémarrage (rescue)", + "unrescuing": "Redémarrage", + "snapshotting": "Création d'une instance backup", + "resuming": "Reprise en cours" } diff --git a/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx index 378c12bd4381..dfb00852425c 100644 --- a/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx @@ -1,21 +1,21 @@ import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; import { OsdsChip } from '@ovhcloud/ods-components/react'; import { useTranslation } from 'react-i18next'; -import { InstanceStatus } from '@/data/hooks/instances/useInstances'; +import { TInstanceStatus } from '@/data/hooks/instances/useInstances'; -const StatusChip = ({ status }: { status: InstanceStatus }) => { - const { t } = useTranslation('status'); +const colorBySeverityStatus = { + success: ODS_THEME_COLOR_INTENT.success, + error: ODS_THEME_COLOR_INTENT.error, + warning: ODS_THEME_COLOR_INTENT.warning, + info: ODS_THEME_COLOR_INTENT.info, +}; - const colorBySeverityStatus = { - success: ODS_THEME_COLOR_INTENT.success, - error: ODS_THEME_COLOR_INTENT.error, - warning: ODS_THEME_COLOR_INTENT.warning, - info: ODS_THEME_COLOR_INTENT.info, - }; +const StatusChip = ({ status }: { status: TInstanceStatus }) => { + const { t } = useTranslation('status'); return ( - {t(status.state)} + {t(status.state.toLowerCase())} ); }; diff --git a/packages/manager/apps/pci-instances/src/data/api/instances.ts b/packages/manager/apps/pci-instances/src/data/api/instances.ts index ef821d83e7a2..09d654b9696f 100644 --- a/packages/manager/apps/pci-instances/src/data/api/instances.ts +++ b/packages/manager/apps/pci-instances/src/data/api/instances.ts @@ -1,19 +1,16 @@ import { v6 } from '@ovh-ux/manager-core-api'; import { DeepReadonly } from '@/types/utils.type'; -type InstanceDtoId = string; -type FlavorDtoId = string; -type ImageDtoId = string; -type InstanceDtoAddressType = 'public' | 'private'; +type TInstanceDtoAddressType = 'public' | 'private'; -type InstanceDtoAddress = { +type TInstanceDtoAddress = { ip: string; version: number; - type: InstanceDtoAddressType; + type: TInstanceDtoAddressType; gatewayIp: string; }; -export type InstanceStatusDto = +export type TInstanceStatusDto = | 'ACTIVE' | 'BUILDING' | 'DELETED' @@ -46,19 +43,19 @@ export type InstanceStatusDto = | 'SNAPSHOTTING' | 'RESUMING'; -export type InstanceDto = DeepReadonly<{ - addresses: InstanceDtoAddress[]; - flavorId: FlavorDtoId; +export type TInstanceDto = DeepReadonly<{ + addresses: TInstanceDtoAddress[]; + flavorId: string; flavorName: string; - id: InstanceDtoId; - imageId: ImageDtoId; + id: string; + imageId: string; imageName: string; name: string; region: string; - status: InstanceStatusDto; + status: TInstanceStatusDto; }>; -export type RetrieveInstancesQueryParams = DeepReadonly<{ +export type TRetrieveInstancesQueryParams = DeepReadonly<{ limit: number; sort: string; sortOrder: 'asc' | 'desc'; @@ -67,7 +64,7 @@ export type RetrieveInstancesQueryParams = DeepReadonly<{ searchValue?: string; }>; -export const retrieveInstances = ( +export const getInstances = ( projectId: string, { limit, @@ -76,8 +73,8 @@ export const retrieveInstances = ( offset, searchField, searchValue, - }: RetrieveInstancesQueryParams, -): Promise => + }: TRetrieveInstancesQueryParams, +): Promise => v6 .get(`/cloud/project/${projectId}/aggregated/instance`, { params: { diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx index 0839a8801016..3b61d5f31099 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx @@ -6,19 +6,19 @@ import { renderHook, waitFor, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { FilterComparator } from '@ovh-ux/manager-core-api'; import { - Instance, - InstanceStatus, + TInstance, + TInstanceStatus, useInstances, - UseInstancesQueryParams, + TUseInstancesQueryParams, } from './useInstances'; -import { InstanceDto, InstanceStatusDto } from '@/data/api/instances'; +import { TInstanceDto, TInstanceStatusDto } from '@/data/api/instances'; import { setupInstanceServer } from '@/_mocks_/instances/node'; // builders const instanceDtoBuilder = ( - addresses: InstanceDto['addresses'], - status: InstanceStatusDto, -): InstanceDto => ({ + addresses: TInstanceDto['addresses'], + status: TInstanceStatusDto, +): TInstanceDto => ({ id: `fake-id`, name: `fake-instance-name`, flavorId: `fake-flavor-id`, @@ -31,10 +31,10 @@ const instanceDtoBuilder = ( }); const instanceBuilder = ( - instanceDto: InstanceDto, - addresses: Instance['addresses'], - status: InstanceStatus, -): Instance => ({ + instanceDto: TInstanceDto, + addresses: TInstance['addresses'], + status: TInstanceStatus, +): TInstance => ({ ...instanceDto, addresses, status, @@ -58,34 +58,34 @@ const initQueryClient = () => { // test data type Data = { projectId: string; - queryParamaters: UseInstancesQueryParams; - queryPayload?: InstanceDto[]; - expectedInstances: Instance[]; + queryParamaters: TUseInstancesQueryParams; + queryPayload?: TInstanceDto[]; + expectedInstances: TInstance[]; expectedQueryHasNext: boolean; - expectedInstancesAfterRefetch: Instance[]; + expectedInstancesAfterRefetch: TInstance[]; expectedQueryKey: string[]; }; const fakeProjectId = 'p42b4f068f404ef3832435304a316332'; -const fakeQueryParamaters1: UseInstancesQueryParams = { +const fakeQueryParamaters1: TUseInstancesQueryParams = { limit: 10, sort: 'name', sortOrder: 'asc', filters: [], }; -const fakeQueryParamaters2: UseInstancesQueryParams = { +const fakeQueryParamaters2: TUseInstancesQueryParams = { limit: 1, sort: 'name', sortOrder: 'asc', filters: [], }; -const fakeQueryParamaters3: UseInstancesQueryParams = { +const fakeQueryParamaters3: TUseInstancesQueryParams = { limit: 10, sort: 'image', sortOrder: 'desc', filters: [], }; -const fakeQueryParamaters4: UseInstancesQueryParams = { +const fakeQueryParamaters4: TUseInstancesQueryParams = { limit: 10, sort: 'flavor', sortOrder: 'asc', @@ -99,17 +99,17 @@ const fakeQueryParamaters4: UseInstancesQueryParams = { ], }; -const fakeInstanceDto1: InstanceDto = instanceDtoBuilder([], 'ACTIVE'); -const fakeInstance1: Instance = instanceBuilder(fakeInstanceDto1, new Map(), { +const fakeInstanceDto1: TInstanceDto = instanceDtoBuilder([], 'ACTIVE'); +const fakeInstance1: TInstance = instanceBuilder(fakeInstanceDto1, new Map(), { state: 'ACTIVE', severity: 'success', }); -const fakeInstanceDto2: InstanceDto = instanceDtoBuilder( +const fakeInstanceDto2: TInstanceDto = instanceDtoBuilder( [{ type: 'private', ip: '192.00.123.34', gatewayIp: '', version: 1 }], 'ERROR', ); -const fakeInstance2: Instance = instanceBuilder( +const fakeInstance2: TInstance = instanceBuilder( fakeInstanceDto2, new Map().set('private', [ { @@ -121,7 +121,7 @@ const fakeInstance2: Instance = instanceBuilder( { state: 'ERROR', severity: 'error' }, ); -const fakeInstanceDto3: InstanceDto = instanceDtoBuilder( +const fakeInstanceDto3: TInstanceDto = instanceDtoBuilder( [ { type: 'private', ip: '192.00.123.34', gatewayIp: '', version: 1 }, { type: 'public', ip: '193.02.689.00', gatewayIp: '', version: 2 }, @@ -129,7 +129,7 @@ const fakeInstanceDto3: InstanceDto = instanceDtoBuilder( ], 'DELETING', ); -const fakeInstance3: Instance = instanceBuilder( +const fakeInstance3: TInstance = instanceBuilder( fakeInstanceDto3, new Map() .set('private', [ @@ -146,14 +146,14 @@ const fakeInstance3: Instance = instanceBuilder( { state: 'DELETING', severity: 'info' }, ); -const fakeInstanceDto4: InstanceDto = instanceDtoBuilder( +const fakeInstanceDto4: TInstanceDto = instanceDtoBuilder( [ { type: 'public', ip: '193.02.689.00', gatewayIp: '', version: 2 }, { type: 'public', ip: '193.02.689.00', gatewayIp: '', version: 7 }, ], 'BUILD', ); -const fakeInstance4: Instance = instanceBuilder( +const fakeInstance4: TInstance = instanceBuilder( fakeInstanceDto4, new Map().set('public', [ { @@ -233,7 +233,7 @@ describe('UseInstances hook', () => { test(`When invoking useInstances() hook', then, expect the computed instances to be '${JSON.stringify( expectedInstances, )}' and the query hasNext property to be ${expectedQueryHasNext}`, async () => { - server = setupInstanceServer(queryPayload); + server = setupInstanceServer(queryPayload); const { wrapper, queryClient } = initQueryClient(); const { result } = renderHook( diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts index 5f3cd3964c1e..48673cb53518 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts @@ -1,73 +1,56 @@ -import { ApiError } from '@ovh-ux/manager-core-api'; import { InfiniteData, keepPreviousData, useInfiniteQuery, - UseInfiniteQueryResult, useQueryClient, } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo } from 'react'; import { FilterWithLabel } from '@ovh-ux/manager-react-components/src/components/filters/interface'; import { - InstanceDto, - retrieveInstances, - InstanceStatusDto, + TInstanceDto, + getInstances, + TInstanceStatusDto, } from '@/data/api/instances'; import { instancesQueryKey } from '@/utils'; import { DeepReadonly } from '@/types/utils.type'; -type UseInstancesResult = UseInfiniteQueryResult< - Instance[] | undefined, - ApiError -> & { - hasInconsistency: boolean; -}; - -export type UseInstances = ( - projectId: string, - params: UseInstancesQueryParams, -) => UseInstancesResult; - -export type UseInstancesQueryParams = DeepReadonly<{ +export type TUseInstancesQueryParams = DeepReadonly<{ limit: number; sort: string; sortOrder: 'asc' | 'desc'; filters: FilterWithLabel[]; }>; -export type InstanceId = string; -export type FlavorId = string; -export type ImageId = string; -export type AddressType = 'public' | 'private'; +export type TAddressType = 'public' | 'private'; -export type InstanceStatusSeverity = 'success' | 'error' | 'warning' | 'info'; -export type InstanceStatusState = InstanceStatusDto; -export type InstanceStatus = { - state: InstanceStatusState; - severity: InstanceStatusSeverity; +export type TInstanceStatusSeverity = 'success' | 'error' | 'warning' | 'info'; +export type TInstanceStatusState = TInstanceStatusDto; +export type TInstanceStatus = { + state: TInstanceStatusState; + severity: TInstanceStatusSeverity; }; -export type Address = { +export type TAddress = { ip: string; version: number; gatewayIp: string; }; -export type Instance = DeepReadonly<{ - id: InstanceId; +export type TInstance = DeepReadonly<{ + id: string; name: string; - flavorId: FlavorId; + flavorId: string; flavorName: string; - status: InstanceStatus; + status: TInstanceStatus; region: string; - imageId: ImageId; + imageId: string; imageName: string; - addresses: Map; + addresses: Map; }>; const buildInstanceStatusSeverity = ( - status: InstanceStatusDto, -): InstanceStatusSeverity => { + status: TInstanceStatusDto, +): TInstanceStatusSeverity => { switch (status) { case 'BUILDING': case 'REBOOT': @@ -104,18 +87,18 @@ const buildInstanceStatusSeverity = ( return 'info'; } }; -const getInstanceStatus = (status: InstanceStatusDto): InstanceStatus => ({ +const getInstanceStatus = (status: TInstanceStatusDto): TInstanceStatus => ({ state: status, severity: buildInstanceStatusSeverity(status), }); -const getInconsistency = (data: Instance[] | undefined): boolean => +const getInconsistency = (data: TInstance[] | undefined): boolean => !!data?.some((elt) => elt.status.state === 'UNKNOWN'); export const instancesSelector = ( - { pages }: InfiniteData, + { pages }: InfiniteData, limit: number, -): Instance[] => +): TInstance[] => pages .flatMap((page) => (page.length > limit ? page.slice(0, limit) : page)) .map((instanceDto) => ({ @@ -131,12 +114,12 @@ export const instancesSelector = ( return acc.set(type, [...foundAddresses, rest]); } return acc.set(type, [rest]); - }, new Map()), + }, new Map()), })); export const useInstances = ( projectId: string, - { limit, sort, sortOrder, filters }: UseInstancesQueryParams, + { limit, sort, sortOrder, filters }: TUseInstancesQueryParams, ) => { const queryClient = useQueryClient(); const filtersQueryKey = useMemo( @@ -173,7 +156,7 @@ export const useInstances = ( }, [projectId, queryClient]); useEffect(() => { - const queryData = queryClient.getQueryData>( + const queryData = queryClient.getQueryData>( queryKey, ); if (queryData?.pageParams && queryData.pageParams.length > 1) { @@ -191,7 +174,7 @@ export const useInstances = ( getNextPageParam: (lastPage, _allPages, lastPageParam) => lastPage.length > limit ? lastPageParam + 1 : null, queryFn: ({ pageParam }) => - retrieveInstances(projectId, { + getInstances(projectId, { limit, sort, sortOrder, @@ -200,7 +183,7 @@ export const useInstances = ( ...(filters.length > 0 && { searchValue: filters[0].value as string }), }), select: useCallback( - (rawData: InfiniteData) => + (rawData: InfiniteData) => instancesSelector(rawData, limit), [limit], ), diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index 43c70f739843..54ddd8db460c 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -40,7 +40,7 @@ import { import { Trans, useTranslation } from 'react-i18next'; import { FilterComparator } from '@ovh-ux/manager-core-api'; import { Spinner } from '@/components/spinner/Spinner.component'; -import { Instance, useInstances } from '@/data/hooks/instances/useInstances'; +import { TInstance, useInstances } from '@/data/hooks/instances/useInstances'; import StatusChip from '@/components/statusChip/StatusChip.component'; const initialSort = { @@ -82,11 +82,43 @@ const Instances: FC = () => { filters, }); - const datagridColumns: DatagridColumn[] = useMemo( + const textCell = useCallback( + (props: TInstance, key: 'flavorName' | 'region' | 'imageName') => + isRefetching ? ( + + ) : ( + {props[key]} + ), + [isRefetching], + ); + + const listCell = useCallback( + (props: TInstance, key: 'public' | 'private') => + isRefetching ? ( + + ) : ( + +
    + {props.addresses.get(key)?.map((item) => ( +
  • + {item.ip} +
  • + ))} +
+
+ ), + [isRefetching], + ); + + const datagridColumns: DatagridColumn[] = useMemo( () => [ { id: 'name', - cell: (props: Instance) => + cell: (props) => isRefetching ? ( ) : ( @@ -114,86 +146,37 @@ const Instances: FC = () => { }, { id: 'region', - cell: (props: Instance) => - isRefetching ? ( - - ) : ( - {props.region} - ), + cell: (props: TInstance) => textCell(props, 'region'), label: t('region'), isSortable: false, }, { id: 'flavor', - cell: (props: Instance) => - isRefetching ? ( - - ) : ( - {props.flavorName} - ), + cell: (props: TInstance) => textCell(props, 'flavorName'), label: t('flavor'), isSortable: true, }, { id: 'image', - cell: (props: Instance) => - isRefetching ? ( - - ) : ( - {props.imageName} - ), + cell: (props: TInstance) => textCell(props, 'imageName'), label: t('image'), isSortable: true, }, { id: 'publicIPs', - cell: (props: Instance) => - isRefetching ? ( - - ) : ( - -
    - {props.addresses.get('public')?.map((item) => ( -
  • - {item.ip} -
  • - ))} -
-
- ), - label: t('publicIPs'), + cell: (props: TInstance) => listCell(props, 'public'), + label: t('public_IPs'), isSortable: false, }, { id: 'privateIPs', - cell: (props: Instance) => - isRefetching ? ( - - ) : ( - -
    - {props.addresses.get('private')?.map((item) => ( -
  • - {item.ip} -
  • - ))} -
-
- ), - label: t('privateIPs'), + cell: (props: TInstance) => listCell(props, 'private'), + label: t('private_IPs'), isSortable: false, }, { id: 'status', - cell: (props: Instance) => + cell: (props: TInstance) => isRefetching ? ( ) : ( @@ -203,7 +186,7 @@ const Instances: FC = () => { isSortable: false, }, ], - [isRefetching, t], + [isRefetching, listCell, t, textCell], ); const filterColumns = useMemo( @@ -242,7 +225,7 @@ const Instances: FC = () => { <> { }} />
- + ), [handleRefresh, t], @@ -289,7 +272,7 @@ const Instances: FC = () => { }, [data, filters.length, isFetching, navigate, projectId]); useEffect(() => { - if (hasInconsistency) addWarning(t('inconsistencyMessage'), true); + if (hasInconsistency) addWarning(t('inconsistency_message'), true); return () => { clearNotifications(); }; @@ -322,7 +305,7 @@ const Instances: FC = () => { )}
- {t('instancesTitle')} + {t('instances_title')}
@@ -344,7 +327,7 @@ const Instances: FC = () => { color={ODS_THEME_COLOR_INTENT.primary} className="mr-4" /> - {t('createInstance')} + {t('create_instance')}
diff --git a/packages/manager/apps/pci-instances/src/types/utils.type.ts b/packages/manager/apps/pci-instances/src/types/utils.type.ts index 8923e12a73c2..e8293e3d4774 100644 --- a/packages/manager/apps/pci-instances/src/types/utils.type.ts +++ b/packages/manager/apps/pci-instances/src/types/utils.type.ts @@ -4,7 +4,7 @@ export type Primitive = | boolean | string | number - | (() => any); + | (() => unknown); export type DeepReadonlyMap = ReadonlyMap< DeepReadonly, DeepReadonly From 94f92504b507868630256060bca1d246fb3cea51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 17 Sep 2024 19:54:55 +0200 Subject: [PATCH 21/76] feat(pci-instances): move api types to dedicated folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../pci-instances/src/data/api/instances.ts | 65 +------------------ .../hooks/instances/useInstances.spec.tsx | 2 +- .../src/data/hooks/instances/useInstances.ts | 7 +- .../apps/pci-instances/src/types/api.types.ts | 64 ++++++++++++++++++ 4 files changed, 68 insertions(+), 70 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/types/api.types.ts diff --git a/packages/manager/apps/pci-instances/src/data/api/instances.ts b/packages/manager/apps/pci-instances/src/data/api/instances.ts index 09d654b9696f..c43af9725337 100644 --- a/packages/manager/apps/pci-instances/src/data/api/instances.ts +++ b/packages/manager/apps/pci-instances/src/data/api/instances.ts @@ -1,68 +1,5 @@ import { v6 } from '@ovh-ux/manager-core-api'; -import { DeepReadonly } from '@/types/utils.type'; - -type TInstanceDtoAddressType = 'public' | 'private'; - -type TInstanceDtoAddress = { - ip: string; - version: number; - type: TInstanceDtoAddressType; - gatewayIp: string; -}; - -export type TInstanceStatusDto = - | 'ACTIVE' - | 'BUILDING' - | 'DELETED' - | 'DELETING' - | 'ERROR' - | 'HARD_REBOOT' - | 'PASSWORD' - | 'PAUSED' - | 'REBOOT' - | 'REBUILD' - | 'RESCUED' - | 'RESIZED' - | 'REVERT_RESIZE' - | 'SOFT_DELETED' - | 'STOPPED' - | 'SUSPENDED' - | 'UNKNOWN' - | 'VERIFY_RESIZE' - | 'MIGRATING' - | 'RESIZE' - | 'BUILD' - | 'SHUTOFF' - | 'RESCUE' - | 'SHELVED' - | 'SHELVING' - | 'UNSHELVING' - | 'SHELVED_OFFLOADED' - | 'RESCUING' - | 'UNRESCUING' - | 'SNAPSHOTTING' - | 'RESUMING'; - -export type TInstanceDto = DeepReadonly<{ - addresses: TInstanceDtoAddress[]; - flavorId: string; - flavorName: string; - id: string; - imageId: string; - imageName: string; - name: string; - region: string; - status: TInstanceStatusDto; -}>; - -export type TRetrieveInstancesQueryParams = DeepReadonly<{ - limit: number; - sort: string; - sortOrder: 'asc' | 'desc'; - offset?: number; - searchField?: string; - searchValue?: string; -}>; +import { TInstanceDto, TRetrieveInstancesQueryParams } from '@/types/api.types'; export const getInstances = ( projectId: string, diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx index 3b61d5f31099..35db662fb3a3 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx @@ -11,8 +11,8 @@ import { useInstances, TUseInstancesQueryParams, } from './useInstances'; -import { TInstanceDto, TInstanceStatusDto } from '@/data/api/instances'; import { setupInstanceServer } from '@/_mocks_/instances/node'; +import { TInstanceDto, TInstanceStatusDto } from '@/types/api.types'; // builders const instanceDtoBuilder = ( diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts index 48673cb53518..9da9ff5c6c22 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts @@ -6,13 +6,10 @@ import { } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo } from 'react'; import { FilterWithLabel } from '@ovh-ux/manager-react-components/src/components/filters/interface'; -import { - TInstanceDto, - getInstances, - TInstanceStatusDto, -} from '@/data/api/instances'; +import { getInstances } from '@/data/api/instances'; import { instancesQueryKey } from '@/utils'; import { DeepReadonly } from '@/types/utils.type'; +import { TInstanceDto, TInstanceStatusDto } from '@/types/api.types'; export type TUseInstancesQueryParams = DeepReadonly<{ limit: number; diff --git a/packages/manager/apps/pci-instances/src/types/api.types.ts b/packages/manager/apps/pci-instances/src/types/api.types.ts new file mode 100644 index 000000000000..d8d7fd875ddb --- /dev/null +++ b/packages/manager/apps/pci-instances/src/types/api.types.ts @@ -0,0 +1,64 @@ +import { DeepReadonly } from './utils.type'; + +export type TInstanceDtoAddressType = 'public' | 'private'; + +export type TInstanceDtoAddress = { + ip: string; + version: number; + type: TInstanceDtoAddressType; + gatewayIp: string; +}; + +export type TInstanceStatusDto = + | 'ACTIVE' + | 'BUILDING' + | 'DELETED' + | 'DELETING' + | 'ERROR' + | 'HARD_REBOOT' + | 'PASSWORD' + | 'PAUSED' + | 'REBOOT' + | 'REBUILD' + | 'RESCUED' + | 'RESIZED' + | 'REVERT_RESIZE' + | 'SOFT_DELETED' + | 'STOPPED' + | 'SUSPENDED' + | 'UNKNOWN' + | 'VERIFY_RESIZE' + | 'MIGRATING' + | 'RESIZE' + | 'BUILD' + | 'SHUTOFF' + | 'RESCUE' + | 'SHELVED' + | 'SHELVING' + | 'UNSHELVING' + | 'SHELVED_OFFLOADED' + | 'RESCUING' + | 'UNRESCUING' + | 'SNAPSHOTTING' + | 'RESUMING'; + +export type TInstanceDto = DeepReadonly<{ + addresses: TInstanceDtoAddress[]; + flavorId: string; + flavorName: string; + id: string; + imageId: string; + imageName: string; + name: string; + region: string; + status: TInstanceStatusDto; +}>; + +export type TRetrieveInstancesQueryParams = DeepReadonly<{ + limit: number; + sort: string; + sortOrder: 'asc' | 'desc'; + offset?: number; + searchField?: string; + searchValue?: string; +}>; From 2eae3fa119951d844b796673c96878046bc0b5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Wed, 18 Sep 2024 17:04:59 +0200 Subject: [PATCH 22/76] fix(pci-instances): improve cache management for refresh function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../src/data/hooks/instances/useInstances.ts | 80 ++++++++++++++++--- .../src/pages/instances/Instances.page.tsx | 11 +-- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts index 9da9ff5c6c22..5be68ee41f1a 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts @@ -1,6 +1,8 @@ import { InfiniteData, keepPreviousData, + Query, + QueryKey, useInfiniteQuery, useQueryClient, } from '@tanstack/react-query'; @@ -143,26 +145,82 @@ export const useInstances = ( [filtersQueryKey, projectId, sort, sortOrder], ); + const invalidateQuery = useCallback( + (queryKeyToInvalidate: QueryKey) => { + queryClient.invalidateQueries({ + queryKey: queryKeyToInvalidate, + exact: true, + }); + }, + [queryClient], + ); + + const resetQueryCacheData = useCallback( + (cacheQueryKey: QueryKey, previousData: InfiniteData) => { + queryClient.setQueryData>(cacheQueryKey, { + pages: previousData.pages.slice(0, 1), + pageParams: [0], + }); + }, + [queryClient], + ); + + // Custom function to prevent from reloading the whole page by resetting cache only const refresh = useCallback(() => { - queryClient.removeQueries({ - predicate: (query) => - query.queryKey.includes('list') && - query.queryKey !== - instancesQueryKey(projectId, ['list', 'sort', 'name', 'asc']), + const initialQueryKey = instancesQueryKey(projectId, [ + 'list', + 'sort', + 'name', + 'asc', + ]); + + const queryKeyEqualsInitialQueryKey = ( + currentQueryKey: QueryKey, + ): boolean => + JSON.stringify(currentQueryKey) === JSON.stringify(initialQueryKey); + + const isListQuery = (query: Query) => query.queryKey.includes('list'); + + const listCachedQueries = queryClient.getQueriesData({ + predicate: isListQuery, }); - }, [projectId, queryClient]); + + const queryData = queryClient.getQueryData>( + initialQueryKey, + ); + + if (listCachedQueries.length > 1) { + queryClient.removeQueries({ + predicate: (query) => + isListQuery(query) && !queryKeyEqualsInitialQueryKey(query.queryKey), + }); + } + + if (queryData && queryData.pageParams.length > 1) { + resetQueryCacheData(initialQueryKey, queryData); + } + + invalidateQuery(initialQueryKey); + }, [invalidateQuery, projectId, queryClient, resetQueryCacheData]); useEffect(() => { const queryData = queryClient.getQueryData>( queryKey, ); if (queryData?.pageParams && queryData.pageParams.length > 1) { - queryClient.resetQueries({ - queryKey, - exact: true, - }); + resetQueryCacheData(queryKey, queryData); + invalidateQuery(queryKey); } - }, [projectId, sort, sortOrder, queryClient, queryKey, filters.length]); + }, [ + projectId, + sort, + sortOrder, + queryClient, + queryKey, + filters.length, + invalidateQuery, + resetQueryCacheData, + ]); const { data, ...rest } = useInfiniteQuery({ queryKey, diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index 54ddd8db460c..953e97a79596 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -43,7 +43,7 @@ import { Spinner } from '@/components/spinner/Spinner.component'; import { TInstance, useInstances } from '@/data/hooks/instances/useInstances'; import StatusChip from '@/components/statusChip/StatusChip.component'; -const initialSort = { +const initialSorting = { id: 'name', desc: false, }; @@ -53,7 +53,7 @@ const Instances: FC = () => { const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader const project = useRouteLoaderData('root') as PublicCloudProject; const navigate = useNavigate(); - const [sorting, setSorting] = useState(initialSort); + const [sorting, setSorting] = useState(initialSorting); const [searchField, setSearchField] = useState(''); const { filters, addFilter, removeFilter } = useColumnFilters(); const { @@ -211,9 +211,10 @@ const Instances: FC = () => { ); const resetSortAndFilters = useCallback(() => { - setSorting(initialSort); - filters.forEach(removeFilter); - }, [filters]); + if (filters.length) filters.forEach(removeFilter); + if (JSON.stringify(sorting) !== JSON.stringify(initialSorting)) + setSorting(initialSorting); + }, [filters, sorting]); const handleRefresh = useCallback(() => { refresh(); From 6a9e688c0fe76555a637827af7917bc7df6c91cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Wed, 18 Sep 2024 17:17:10 +0200 Subject: [PATCH 23/76] test(pci-instance): update useInstances test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../src/data/hooks/instances/useInstances.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx index 35db662fb3a3..c57ddc211678 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx @@ -272,7 +272,7 @@ describe('UseInstances hook', () => { act(() => result.current.refresh()); expect( queryCache.getAll().map((cache) => cache.queryKey).length, - ).toStrictEqual(0); + ).toStrictEqual(1); }); }); }, From a1f44078f686415568ca2d2c1da0510a262cdf0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 19 Sep 2024 16:53:37 +0200 Subject: [PATCH 24/76] feat(pci-instances): add Breadcrumb component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../breadcrumb/Breadcrumb.component.tsx | 47 +++++++++++++ .../src/pages/instances/Instances.page.tsx | 68 ++++++++----------- 2 files changed, 76 insertions(+), 39 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx diff --git a/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx b/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx new file mode 100644 index 000000000000..9f69de6b1950 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx @@ -0,0 +1,47 @@ +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { OsdsBreadcrumb } from '@ovhcloud/ods-components/react'; +import { FC, useContext, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +type TBreadcrumbItem = { + label: string; + href?: string; +}; + +export type TBreadcrumbProps = { + items?: TBreadcrumbItem[]; + projectLabel: string; +}; + +export const Breadcrumb: FC = ({ + items = [], + projectLabel, +}) => { + const { projectId } = useParams() as { projectId: string }; + const { navigation } = useContext(ShellContext).shell; + const [projectUrl, setProjectUrl] = useState(''); + const { t } = useTranslation('common'); + + useEffect(() => { + navigation + .getURL('public-cloud', `#/pci/projects/${projectId}`, {}) + .then((url: unknown) => setProjectUrl(url as string)); + }, [navigation, projectId]); + + return ( + + ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index 953e97a79596..261ed38bd132 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -7,6 +7,7 @@ import { FilterAdd, FilterList, Notifications, + PageLayout, PciGuidesHeader, PublicCloudProject, Title, @@ -19,7 +20,6 @@ import { ODS_THEME_TYPOGRAPHY_SIZE, } from '@ovhcloud/ods-common-theming'; import { - OsdsBreadcrumb, OsdsButton, OsdsDivider, OsdsIcon, @@ -42,6 +42,7 @@ import { FilterComparator } from '@ovh-ux/manager-core-api'; import { Spinner } from '@/components/spinner/Spinner.component'; import { TInstance, useInstances } from '@/data/hooks/instances/useInstances'; import StatusChip from '@/components/statusChip/StatusChip.component'; +import { Breadcrumb } from '@/components/breadcrumb/Breadcrumb.component'; const initialSorting = { id: 'name', @@ -49,7 +50,8 @@ const initialSorting = { }; const Instances: FC = () => { - const { t } = useTranslation('list'); + const { t: tList } = useTranslation('list'); + const { t: tCommon } = useTranslation('common'); const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader const project = useRouteLoaderData('root') as PublicCloudProject; const navigate = useNavigate(); @@ -76,7 +78,7 @@ const Instances: FC = () => { isRefetching, isError, } = useInstances(projectId, { - limit: 25, + limit: 10, sort: sorting.id, sortOrder: sorting.desc ? 'desc' : 'asc', filters, @@ -141,37 +143,37 @@ const Instances: FC = () => { ), - label: t('nameId'), + label: tList('nameId'), isSortable: true, }, { id: 'region', cell: (props: TInstance) => textCell(props, 'region'), - label: t('region'), + label: tList('region'), isSortable: false, }, { id: 'flavor', cell: (props: TInstance) => textCell(props, 'flavorName'), - label: t('flavor'), + label: tList('flavor'), isSortable: true, }, { id: 'image', cell: (props: TInstance) => textCell(props, 'imageName'), - label: t('image'), + label: tList('image'), isSortable: true, }, { id: 'publicIPs', cell: (props: TInstance) => listCell(props, 'public'), - label: t('public_IPs'), + label: tList('public_IPs'), isSortable: false, }, { id: 'privateIPs', cell: (props: TInstance) => listCell(props, 'private'), - label: t('private_IPs'), + label: tList('private_IPs'), isSortable: false, }, { @@ -182,32 +184,32 @@ const Instances: FC = () => { ) : ( ), - label: t('status'), + label: tList('status'), isSortable: false, }, ], - [isRefetching, listCell, t, textCell], + [isRefetching, listCell, tList, textCell], ); const filterColumns = useMemo( () => [ { id: 'name', - label: t('nameId'), + label: tList('nameId'), comparators: [FilterComparator.Includes], }, { id: 'flavor', - label: t('flavor'), + label: tList('flavor'), comparators: [FilterComparator.Includes], }, { id: 'image', - label: t('image'), + label: tList('image'), comparators: [FilterComparator.Includes], }, ], - [t], + [tList], ); const resetSortAndFilters = useCallback(() => { @@ -225,7 +227,7 @@ const Instances: FC = () => { () => ( <> { }} />
- + ), - [handleRefresh, t], + [handleRefresh, tList], ); const handleOdsSearchSubmit = useCallback( @@ -273,11 +275,11 @@ const Instances: FC = () => { }, [data, filters.length, isFetching, navigate, projectId]); useEffect(() => { - if (hasInconsistency) addWarning(t('inconsistency_message'), true); + if (hasInconsistency) addWarning(tList('inconsistency_message'), true); return () => { clearNotifications(); }; - }, [addWarning, hasInconsistency, t, clearNotifications]); + }, [addWarning, hasInconsistency, tList, clearNotifications]); useEffect(() => { if (isFetching && notifications.length) clearNotifications(); @@ -285,28 +287,16 @@ const Instances: FC = () => { useEffect(() => { if (isError) addError(errorMessage, true); - }, [isError, addError, t, errorMessage]); + }, [isError, addError, tList, errorMessage]); if (isLoading) return ; return ( - <> - {project && ( - - )} + + {project && }
- {t('instances_title')} + {tCommon('instances_title')}
@@ -328,7 +318,7 @@ const Instances: FC = () => { color={ODS_THEME_COLOR_INTENT.primary} className="mr-4" /> - {t('create_instance')} + {tCommon('create_instance')}
@@ -369,7 +359,7 @@ const Instances: FC = () => { className={'mr-2'} color={ODS_THEME_COLOR_INTENT.primary} /> - {t('filter')} + {tList('filter')} {
)}
- + ); }; From 45259e68078afaa4348e1865260075375a36829f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 19 Sep 2024 16:55:30 +0200 Subject: [PATCH 25/76] feat(pci-instances): add translations and instance image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../pci-instances/public/assets/instance.png | Bin 0 -> 177480 bytes .../translations/common/Messages_fr_FR.json | 5 ++++- .../translations/list/Messages_fr_FR.json | 2 -- .../onboarding/Messages_fr_FR.json | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 packages/manager/apps/pci-instances/public/assets/instance.png create mode 100644 packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json diff --git a/packages/manager/apps/pci-instances/public/assets/instance.png b/packages/manager/apps/pci-instances/public/assets/instance.png new file mode 100644 index 0000000000000000000000000000000000000000..d8355bbe5b899454466aa81dade5029f463485e0 GIT binary patch literal 177480 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

cfy^cQGRJw`B!r;&&&oD+Oec|{m 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 z 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ÍENCB8#C2+2}W*K9(1gMr+tK~YYi=f#{6lCWxd-@cX^QhYVdcR*t`p{mAA1H96FL+bBk!Ht5Rn3ws->(2--0@-OPUTXtW&Lwmy@+ zkq)k2IC$%o-b?e1-*^z)WVYEKTme{hD~j@HA{u%0jfl-N@y5Xwi}JDO;kRhz-?}+y zh7}_pte(c37;{wwwDp&;c1OF}86*g|83hZ5T$jN*a}HBe;XBWc{)*OpdTslvhs4HJ zF3Ie=t*_%Rce_f;9yKcQuX0lIX|H?L*RAslwIl5x%iIMbwN>M`wqG$oMY^d%uKfg( zvQmTupaT4MCh_Q6zAjt;n;nmdUpRiV6ziZg;$5s=(cKJuk%0cO8fOfnL%biYBDVWrOHYV98NP{I(`PFWgeC`B#vu1pb|4l1890O?8rJ0PKOL;?j#vpU)rW0c0tkj{aiVhyov*x%BG)u#7Q(C=jc)v8 z$v_opief-gvM>Nc8L4nM0G?05YIQRQZ?b8Cp2DLshk>k$61IIJ9!1C!M{P|78xNcg z3tOLB?aa<-I?}Ww{2L7df$`WPOePs-6Q_YDK9{|+n0%yxgV}os^~}Cou$YqIn^h=v2?YCpiAZl1+-;viJa8J} zffL}l3Y5S9ldv`IE;#Nooz>!xz=^A2Df%V^vSi?vSVKCdr@Q)5UgBcK39K44E6$!B zLUncNifKUtS6**0CzVJsG&FUp05~cHlrHvoBkPa|&~oh!U-Ccm)ctRKJbM%Io&i|K zqG|?S7Eb*eTvz=qHeP=_ild*0kzYh=M!{ky>2|ey#x_GEe97~el=2%tUy67ldI(pK zS1`w4Lhqs)gJbsBu(#}By$CbG1nTM%qm@Y;rh%8j7CTyWeb)KMA-d{eYwXAZB}&%w zlC=XTm|?5g3_0dSWTX>Pcph>h2+m|@CZK{%JLzpk6-`wp#Qjr9#k@#G!{8+zvZ0E7 z9G=ANI>2J7zifXi9ayC+1=WS~FquuTHf>v~4$Kj-b2;h2T(&Cz0)HnWI(mZjKl3^F zKrw0qLYlQIRADrF@-X_(sjz#>(9)_gSYt^UcB7eY6oqpTuM>vXy1wOcTAR0S)&Tx1 zr_Z#{w{QGMXyB^o{}s3bs|>Q;wj5lks`{v$N7CL@_@3B~u4e}Y}Zf{;C# z%+>=d5DYDVp&2gjHC+2X9@DdRINdb|Nj9-Y@vPY~3C>l`*_?qZo$+NAYDBveTy6^14(PaB4ES(+;y z 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 z%0A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 literal 0 HcmV?d00001 diff --git a/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json index 0967ef424bce..965c5617a783 100644 --- a/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json @@ -1 +1,4 @@ -{} +{ + "instances_title": "Instances", + "create_instance": "Créer une instance" +} diff --git a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json index 1b97f4cc8f61..16be1a44327d 100644 --- a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json @@ -6,8 +6,6 @@ "cancel": "Annuler", "image": "Image", "flavor": "Modèle", - "instances_title": "Instances", - "create_instance": "Créer une instance", "region": "Localisation", "filter": "Filtrer", "refresh": "Rafraîchir", diff --git a/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json new file mode 100644 index 000000000000..675eff62cb6c --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json @@ -0,0 +1,15 @@ +{ + "not_created_message": "Vous n'avez pas encore créé d’instance.", + "content_message_1": "Déployez des instances parmi une large gamme de modèles et profitez de la flexibilité du cloud pour croître selon vos besoins.", + "content_message_2": "Dans notre catalogue d'instances, choisissez le modèle qui vous convient parmi la gamme Sandbox, dont les ressources sont partagées, la gamme Guaranteed Resources, aux performances constantes pour les applications exigeantes, la gamme GPU, idéale pour les calculs parallélisés, et la gamme IOPS, pour les bases de données ou le big data.", + "advice_message": "À tout moment, vous avez la possibilité d’augmenter la capacité de vos instances selon vos besoins. Vous pouvez aussi opter pour les instances de type flex, qui permettent également de réduire cette capacité.", + "guide_title": "Tutoriel", + "create_instance_title": "Créer une instance depuis l’espace client", + "post_install_script_title": "Lancer un script lors de la creation d’un instance", + "back_up_instance_title": "Sauvegarder une instance", + "instance_introduction_title": "Introduction aux instances et autres notions cloud", + "create_instance_description": "Ce guide vous montre comment créer une instance sur Public Cloud", + "post_install_script_description": "", + "back_up_instance_description": "Découvrez comment sauvegarder une instance Public Cloud en quelques clics", + "instance_introduction_description": "" +} From f80dd443909978359e346c64229928cb59496f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 19 Sep 2024 16:56:27 +0200 Subject: [PATCH 26/76] feat(pci-instances): add onboarding page and constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../onboarding/Messages_fr_FR.json | 2 +- .../src/pages/instances/Instances.page.tsx | 45 ++++---- .../src/pages/onboarding/Onboarding.page.tsx | 105 +++++++++++++++++- .../pages/onboarding/onboarding.constants.ts | 86 ++++++++++++++ 4 files changed, 212 insertions(+), 26 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/pages/onboarding/onboarding.constants.ts diff --git a/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json index 675eff62cb6c..69934def5f00 100644 --- a/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json @@ -1,5 +1,5 @@ { - "not_created_message": "Vous n'avez pas encore créé d’instance.", + "not_created_message": "Vous n'avez pas encore créé d'instance.", "content_message_1": "Déployez des instances parmi une large gamme de modèles et profitez de la flexibilité du cloud pour croître selon vos besoins.", "content_message_2": "Dans notre catalogue d'instances, choisissez le modèle qui vous convient parmi la gamme Sandbox, dont les ressources sont partagées, la gamme Guaranteed Resources, aux performances constantes pour les applications exigeantes, la gamme GPU, idéale pour les calculs parallélisés, et la gamme IOPS, pour les bases de données ou le big data.", "advice_message": "À tout moment, vous avez la possibilité d’augmenter la capacité de vos instances selon vos besoins. Vous pouvez aussi opter pour les instances de type flex, qui permettent également de réduire cette capacité.", diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index 261ed38bd132..a3b8ae1fc957 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -50,8 +50,7 @@ const initialSorting = { }; const Instances: FC = () => { - const { t: tList } = useTranslation('list'); - const { t: tCommon } = useTranslation('common'); + const { t } = useTranslation(['list', 'common']); const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader const project = useRouteLoaderData('root') as PublicCloudProject; const navigate = useNavigate(); @@ -143,37 +142,37 @@ const Instances: FC = () => { ), - label: tList('nameId'), + label: t('nameId'), isSortable: true, }, { id: 'region', cell: (props: TInstance) => textCell(props, 'region'), - label: tList('region'), + label: t('region'), isSortable: false, }, { id: 'flavor', cell: (props: TInstance) => textCell(props, 'flavorName'), - label: tList('flavor'), + label: t('flavor'), isSortable: true, }, { id: 'image', cell: (props: TInstance) => textCell(props, 'imageName'), - label: tList('image'), + label: t('image'), isSortable: true, }, { id: 'publicIPs', cell: (props: TInstance) => listCell(props, 'public'), - label: tList('public_IPs'), + label: t('public_IPs'), isSortable: false, }, { id: 'privateIPs', cell: (props: TInstance) => listCell(props, 'private'), - label: tList('private_IPs'), + label: t('private_IPs'), isSortable: false, }, { @@ -184,32 +183,32 @@ const Instances: FC = () => { ) : ( ), - label: tList('status'), + label: t('status'), isSortable: false, }, ], - [isRefetching, listCell, tList, textCell], + [isRefetching, listCell, t, textCell], ); const filterColumns = useMemo( () => [ { id: 'name', - label: tList('nameId'), + label: t('nameId'), comparators: [FilterComparator.Includes], }, { id: 'flavor', - label: tList('flavor'), + label: t('flavor'), comparators: [FilterComparator.Includes], }, { id: 'image', - label: tList('image'), + label: t('image'), comparators: [FilterComparator.Includes], }, ], - [tList], + [t], ); const resetSortAndFilters = useCallback(() => { @@ -227,7 +226,7 @@ const Instances: FC = () => { () => ( <> { }} />
- + ), - [handleRefresh, tList], + [handleRefresh, t], ); const handleOdsSearchSubmit = useCallback( @@ -275,11 +274,11 @@ const Instances: FC = () => { }, [data, filters.length, isFetching, navigate, projectId]); useEffect(() => { - if (hasInconsistency) addWarning(tList('inconsistency_message'), true); + if (hasInconsistency) addWarning(t('inconsistency_message'), true); return () => { clearNotifications(); }; - }, [addWarning, hasInconsistency, tList, clearNotifications]); + }, [addWarning, hasInconsistency, t, clearNotifications]); useEffect(() => { if (isFetching && notifications.length) clearNotifications(); @@ -287,7 +286,7 @@ const Instances: FC = () => { useEffect(() => { if (isError) addError(errorMessage, true); - }, [isError, addError, tList, errorMessage]); + }, [isError, addError, t, errorMessage]); if (isLoading) return ; @@ -296,7 +295,7 @@ const Instances: FC = () => { {project && }

- {tCommon('instances_title')} + {t('common:instances_title')}
@@ -318,7 +317,7 @@ const Instances: FC = () => { color={ODS_THEME_COLOR_INTENT.primary} className="mr-4" /> - {tCommon('create_instance')} + {t('common:create_instance')}
@@ -359,7 +358,7 @@ const Instances: FC = () => { className={'mr-2'} color={ODS_THEME_COLOR_INTENT.primary} /> - {tList('filter')} + {t('filter')}

Onboarding

; +const Onboarding: FC = () => { + const { t } = useTranslation(['onboarding', 'common']); + const { projectId } = useParams() as { projectId: string }; + const context = useContext(ShellContext); + const { ovhSubsidiary } = context.environment.getUser() as { + ovhSubsidiary: OvhSubsidiary; + }; + const project = useRouteLoaderData('root') as PublicCloudProject; + useHidePreloader(); + + const { data, isLoading } = useInstances(projectId, { + limit: 10, + sort: 'name', + sortOrder: 'asc', + filters: [], + }); + + if (isLoading) return ; + + return data && data.length > 0 ? ( + + ) : ( + + {project && } + + + {t('not_created_message')} + + + {t('content_message_1')} + + + {t('content_message_2')} + + + {t('advice_message')} + + + } + > + {GUIDES.map((guide) => ( + + ))} + + + ); +}; export default Onboarding; diff --git a/packages/manager/apps/pci-instances/src/pages/onboarding/onboarding.constants.ts b/packages/manager/apps/pci-instances/src/pages/onboarding/onboarding.constants.ts new file mode 100644 index 000000000000..71e8589df591 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/onboarding/onboarding.constants.ts @@ -0,0 +1,86 @@ +import { OvhSubsidiary } from '@ovh-ux/manager-react-components'; + +export type TGuide = { + id: string; + links: Partial>; +}; + +const HELP_OVH_BASE_URL = 'https://help.ovhcloud.com/csm'; +const SUPPORT_US_OVH_BASE_URL = + 'https://support.us.ovhcloud.com/hc/en-us/articles'; + +export const GUIDES: TGuide[] = [ + { + id: 'create_instance', + links: { + DEFAULT: `${HELP_OVH_BASE_URL}/en-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051009`, + DE: `${HELP_OVH_BASE_URL}/de-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051002`, + ASIA: `${HELP_OVH_BASE_URL}/asia-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0038732`, + AU: `${HELP_OVH_BASE_URL}/en-au-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051007`, + CA: `${HELP_OVH_BASE_URL}/en-ca-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051008`, + GB: `${HELP_OVH_BASE_URL}/en-gb-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051017`, + IE: `${HELP_OVH_BASE_URL}/en-ie-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051014`, + SG: `${HELP_OVH_BASE_URL}/en-sg-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051004`, + ES: `${HELP_OVH_BASE_URL}/es-es-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051005`, + WS: `${HELP_OVH_BASE_URL}/es-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051006`, + QC: `${HELP_OVH_BASE_URL}/fr-ca-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051013`, + FR: `${HELP_OVH_BASE_URL}/fr-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051011`, + IT: `${HELP_OVH_BASE_URL}/it-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051025`, + PL: `${HELP_OVH_BASE_URL}/pl-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051024`, + PT: `${HELP_OVH_BASE_URL}/pt-public-cloud-compute-getting-started?id=kb_article_view&sysparm_article=KB0051015`, + US: `${SUPPORT_US_OVH_BASE_URL}/360002245164-How-to-Create-and-Connect-a-Public-Cloud-Instance`, + }, + }, + { + id: 'post_install_script', + links: { + ASIA: `${HELP_OVH_BASE_URL}/asia-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050910`, + AU: `${HELP_OVH_BASE_URL}/en-au-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0038632`, + CA: `${HELP_OVH_BASE_URL}/en-ca-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050911`, + GB: `${HELP_OVH_BASE_URL}/en-gb-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050914`, + SG: `${HELP_OVH_BASE_URL}/en-sg-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050913`, + DEFAULT: `${HELP_OVH_BASE_URL}/en-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050912`, + QC: `${HELP_OVH_BASE_URL}/fr-ca-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050923`, + FR: `${HELP_OVH_BASE_URL}/fr-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050924`, + IT: `${HELP_OVH_BASE_URL}/it-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050916`, + PL: `${HELP_OVH_BASE_URL}/pl-public-cloud-compute-launch-script-at-instance-creation?id=kb_article_view&sysparm_article=KB0050919`, + US: `${SUPPORT_US_OVH_BASE_URL}/19905625883923-Getting-Started-with-the-OpenStack-API`, + }, + }, + { + id: 'back_up_instance', + links: { + DE: `${HELP_OVH_BASE_URL}/de-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051161`, + ASIA: `${HELP_OVH_BASE_URL}/asia-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051147`, + AU: `${HELP_OVH_BASE_URL}/en-au-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051148`, + CA: `${HELP_OVH_BASE_URL}/en-ca-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051149`, + GB: `${HELP_OVH_BASE_URL}/en-gb-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0038893`, + IE: `${HELP_OVH_BASE_URL}/en-ie-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051157`, + SG: `${HELP_OVH_BASE_URL}/en-sg-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051158`, + DEFAULT: `${HELP_OVH_BASE_URL}/en-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051162`, + ES: `${HELP_OVH_BASE_URL}/es-es-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051152`, + WS: `${HELP_OVH_BASE_URL}/es-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051153`, + QC: `${HELP_OVH_BASE_URL}/fr-ca-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051154`, + FR: `${HELP_OVH_BASE_URL}/fr-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051155`, + IT: `${HELP_OVH_BASE_URL}/it-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051165`, + PL: `${HELP_OVH_BASE_URL}/pl-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051167`, + PT: `${HELP_OVH_BASE_URL}/pt-public-cloud-compute-back-up-instance?id=kb_article_view&sysparm_article=KB0051159`, + US: `${SUPPORT_US_OVH_BASE_URL}/4460743125395-How-to-Back-Up-a-Public-Cloud-Instance`, + }, + }, + { + id: 'instance_introduction', + links: { + ASIA: `${HELP_OVH_BASE_URL}/asia-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0050909`, + AU: `${HELP_OVH_BASE_URL}/en-au-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0050907`, + CA: `${HELP_OVH_BASE_URL}/en-ca-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0050908`, + GB: `${HELP_OVH_BASE_URL}/en-gb-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0038620`, + IE: `${HELP_OVH_BASE_URL}/en-ie-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0050902`, + SG: `${HELP_OVH_BASE_URL}/en-sg-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0050899`, + DEFAULT: `${HELP_OVH_BASE_URL}/en-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0050915`, + QC: `${HELP_OVH_BASE_URL}/fr-ca-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0050917`, + FR: `${HELP_OVH_BASE_URL}/fr-public-cloud-compute-glossary?id=kb_article_view&sysparm_article=KB0050903`, + US: `${SUPPORT_US_OVH_BASE_URL}/21740390395283-Public-Cloud-Glossary`, + }, + }, +]; From b7a2981f8a7d8533744deefec38d330702d0579f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 20 Sep 2024 17:41:29 +0200 Subject: [PATCH 27/76] fix(pci-instances): add missing namespace for create button i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx b/packages/manager/apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx index 770fa77f76fc..a54a96ca46aa 100644 --- a/packages/manager/apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/onboarding/Onboarding.page.tsx @@ -49,7 +49,7 @@ const Onboarding: FC = () => { Date: Mon, 23 Sep 2024 10:38:49 +0200 Subject: [PATCH 28/76] feat(pci-instances): add manager-pci-common package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- packages/manager/apps/pci-instances/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/manager/apps/pci-instances/package.json b/packages/manager/apps/pci-instances/package.json index aded20fca6ab..3db6730a23a6 100644 --- a/packages/manager/apps/pci-instances/package.json +++ b/packages/manager/apps/pci-instances/package.json @@ -18,6 +18,7 @@ "dependencies": { "@ovh-ux/manager-config": "^7.3.2", "@ovh-ux/manager-core-api": "^0.8.0", + "@ovh-ux/manager-pci-common": "^0.3.0", "@ovh-ux/manager-react-components": "^1.26.0", "@ovh-ux/manager-react-core-application": "^0.10.0", "@ovh-ux/manager-react-shell-client": "^0.7.0", From 3f395e58fb9713f143c9861778b2003f23270926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Mon, 23 Sep 2024 10:42:40 +0200 Subject: [PATCH 29/76] feat(pci-instances): add createInstance page and declare route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../instances/create/CreateInstance.page.tsx | 56 +++++++++++++++++++ .../apps/pci-instances/src/routes/routes.tsx | 13 ++++- 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx new file mode 100644 index 000000000000..2890cea11153 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx @@ -0,0 +1,56 @@ +import { + PageLayout, + StepComponent, + Title, +} from '@ovh-ux/manager-react-components'; +import { FC, useMemo } from 'react'; +import { useRouteLoaderData } from 'react-router-dom'; +import { TProject } from '@ovh-ux/manager-pci-common'; +import { useTranslation } from 'react-i18next'; +import { OsdsDivider } from '@ovhcloud/ods-components/react'; +import { + Breadcrumb, + TBreadcrumbProps, +} from '@/components/breadcrumb/Breadcrumb.component'; +import { useHidePreloader } from '@/hooks/hidePreloader/useHidePreloader'; + +const CreateInstance: FC = () => { + const project = useRouteLoaderData('root') as TProject; + const { t } = useTranslation(['create', 'common']); + const breadcrumbItems = useMemo( + () => [ + { + label: t('common:create_instance'), + }, + ], + [t], + ); + + useHidePreloader(); + + return ( + + {project && ( + + )} +
+ {t('common:create_instance')} +
+ +
+ +
+
+ ); +}; + +export default CreateInstance; diff --git a/packages/manager/apps/pci-instances/src/routes/routes.tsx b/packages/manager/apps/pci-instances/src/routes/routes.tsx index 6eef34e6cf2a..2c8325d76fa5 100644 --- a/packages/manager/apps/pci-instances/src/routes/routes.tsx +++ b/packages/manager/apps/pci-instances/src/routes/routes.tsx @@ -1,5 +1,5 @@ import { RouteObject } from 'react-router-dom'; -import { getProjectQuery } from '@ovh-ux/manager-react-components'; +import { getProjectQuery } from '@ovh-ux/manager-pci-common'; import queryClient from '@/queryClient'; const lazyRouteConfig = (importFn: CallableFunction) => ({ @@ -16,6 +16,7 @@ const lazyRouteConfig = (importFn: CallableFunction) => ({ export const ROOT_PATH = '/pci/projects/:projectId/instances'; export const SUB_PATHS = { onboarding: 'onboarding', + new: 'new', }; const routes: RouteObject[] = [ @@ -36,7 +37,15 @@ const routes: RouteObject[] = [ }, { path: SUB_PATHS.onboarding, - ...lazyRouteConfig(() => import('@/pages/onboarding/Onboarding.page')), + ...lazyRouteConfig(() => + import('@/pages/instances/onboarding/Onboarding.page'), + ), + }, + { + path: SUB_PATHS.new, + ...lazyRouteConfig(() => + import('@/pages/instances/create/CreateInstance.page'), + ), }, ], }, From 5091b41d484647d39a1f8b415c84857d76215f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Mon, 23 Sep 2024 10:46:12 +0200 Subject: [PATCH 30/76] refactor(pci-instances): update root page folder & improve breadcrumb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../breadcrumb/Breadcrumb.component.tsx | 19 ++++-------- .../src/pages/instances/Instances.page.tsx | 11 +++++-- .../onboarding/Onboarding.page.tsx | 29 ++++++++++++++----- .../onboarding/onboarding.constants.ts | 0 4 files changed, 36 insertions(+), 23 deletions(-) rename packages/manager/apps/pci-instances/src/pages/{ => instances}/onboarding/Onboarding.page.tsx (81%) rename packages/manager/apps/pci-instances/src/pages/{ => instances}/onboarding/onboarding.constants.ts (100%) diff --git a/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx b/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx index 9f69de6b1950..0a9606cc12de 100644 --- a/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx @@ -1,8 +1,8 @@ -import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { useProjectUrl } from '@ovh-ux/manager-react-components'; import { OsdsBreadcrumb } from '@ovhcloud/ods-components/react'; -import { FC, useContext, useEffect, useState } from 'react'; +import { FC } from 'react'; import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; +import { useHref } from 'react-router-dom'; type TBreadcrumbItem = { label: string; @@ -18,17 +18,10 @@ export const Breadcrumb: FC = ({ items = [], projectLabel, }) => { - const { projectId } = useParams() as { projectId: string }; - const { navigation } = useContext(ShellContext).shell; - const [projectUrl, setProjectUrl] = useState(''); + const backHref = useHref('..'); + const projectUrl = useProjectUrl('public-cloud'); const { t } = useTranslation('common'); - useEffect(() => { - navigation - .getURL('public-cloud', `#/pci/projects/${projectId}`, {}) - .then((url: unknown) => setProjectUrl(url as string)); - }, [navigation, projectId]); - return ( = ({ label: projectLabel, }, { - href: `${projectUrl}/instances`, + href: backHref, label: t('instances_title'), }, ...items, diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index a3b8ae1fc957..b6f2bf64f8f7 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -1,5 +1,6 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams, useRouteLoaderData } from 'react-router-dom'; +import { TProject } from '@ovh-ux/manager-pci-common'; import { Datagrid, DatagridColumn, @@ -9,7 +10,6 @@ import { Notifications, PageLayout, PciGuidesHeader, - PublicCloudProject, Title, useColumnFilters, useNotifications, @@ -52,7 +52,7 @@ const initialSorting = { const Instances: FC = () => { const { t } = useTranslation(['list', 'common']); const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader - const project = useRouteLoaderData('root') as PublicCloudProject; + const project = useRouteLoaderData('root') as TProject; const navigate = useNavigate(); const [sorting, setSorting] = useState(initialSorting); const [searchField, setSearchField] = useState(''); @@ -268,6 +268,10 @@ const Instances: FC = () => { fetchNextPage(); }, [fetchNextPage]); + const createInstance = useCallback(() => { + navigate('./new'); + }, [navigate]); + useEffect(() => { if (data && !filters.length && !data.length && !isFetching) navigate(`/pci/projects/${projectId}/instances/onboarding`); @@ -292,7 +296,7 @@ const Instances: FC = () => { return ( - {project && } + {project && }
{t('common:instances_title')} @@ -309,6 +313,7 @@ const Instances: FC = () => { variant={ODS_BUTTON_VARIANT.stroked} color={ODS_THEME_COLOR_INTENT.primary} inline + onClick={createInstance} > { const { ovhSubsidiary } = context.environment.getUser() as { ovhSubsidiary: OvhSubsidiary; }; - const project = useRouteLoaderData('root') as PublicCloudProject; + const project = useRouteLoaderData('root') as TProject; + const navigate = useNavigate(); useHidePreloader(); const { data, isLoading } = useInstances(projectId, { @@ -39,17 +45,26 @@ const Onboarding: FC = () => { filters: [], }); + const rootUrl = useMemo(() => `/pci/projects/${projectId}/instances`, [ + projectId, + ]); + + const createInstance = useCallback(() => { + navigate('../new'); + }, [navigate]); + if (isLoading) return ; return data && data.length > 0 ? ( - + ) : ( - {project && } + {project && } Date: Mon, 23 Sep 2024 10:47:22 +0200 Subject: [PATCH 31/76] feat(pci-instances): add create translations file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../public/translations/create/Messages_fr_FR.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json diff --git a/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json new file mode 100644 index 000000000000..e2da3634afce --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json @@ -0,0 +1,3 @@ +{ + "select_template": "Sélectionnez un modèle" +} From 0517c3b24d2b9923a180ebf7a12563dee9c56f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Mon, 23 Sep 2024 11:25:18 +0200 Subject: [PATCH 32/76] feat(pci-instances): use hrefs for button links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../src/pages/instances/Instances.page.tsx | 14 ++++++++------ .../pages/instances/onboarding/Onboarding.page.tsx | 12 ++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index b6f2bf64f8f7..849db955d0c9 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -1,5 +1,10 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate, useParams, useRouteLoaderData } from 'react-router-dom'; +import { + useHref, + useNavigate, + useParams, + useRouteLoaderData, +} from 'react-router-dom'; import { TProject } from '@ovh-ux/manager-pci-common'; import { Datagrid, @@ -54,6 +59,7 @@ const Instances: FC = () => { const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader const project = useRouteLoaderData('root') as TProject; const navigate = useNavigate(); + const createInstanceHref = useHref('./new'); const [sorting, setSorting] = useState(initialSorting); const [searchField, setSearchField] = useState(''); const { filters, addFilter, removeFilter } = useColumnFilters(); @@ -268,10 +274,6 @@ const Instances: FC = () => { fetchNextPage(); }, [fetchNextPage]); - const createInstance = useCallback(() => { - navigate('./new'); - }, [navigate]); - useEffect(() => { if (data && !filters.length && !data.length && !isFetching) navigate(`/pci/projects/${projectId}/instances/onboarding`); @@ -313,7 +315,7 @@ const Instances: FC = () => { variant={ODS_BUTTON_VARIANT.stroked} color={ODS_THEME_COLOR_INTENT.primary} inline - onClick={createInstance} + href={createInstanceHref} > { ovhSubsidiary: OvhSubsidiary; }; const project = useRouteLoaderData('root') as TProject; - const navigate = useNavigate(); + const createInstanceHref = useHref('../new'); useHidePreloader(); const { data, isLoading } = useInstances(projectId, { @@ -49,10 +49,6 @@ const Onboarding: FC = () => { projectId, ]); - const createInstance = useCallback(() => { - navigate('../new'); - }, [navigate]); - if (isLoading) return ; return data && data.length > 0 ? ( @@ -64,7 +60,7 @@ const Onboarding: FC = () => { title={t('common:instances_title')} img={{ src: InstanceImageSrc }} orderButtonLabel={t('common:create_instance')} - onOrderButtonClick={createInstance} + orderHref={createInstanceHref} description={ <> Date: Wed, 25 Sep 2024 10:06:50 +0200 Subject: [PATCH 33/76] refactor(pci-instances): restructure types folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../manager/apps/pci-instances/src/data/api/instances.ts | 5 ++++- .../src/data/hooks/instances/useInstances.spec.tsx | 2 +- .../pci-instances/src/data/hooks/instances/useInstances.ts | 2 +- .../pci-instances/src/types/{ => instances}/api.types.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) rename packages/manager/apps/pci-instances/src/types/{ => instances}/api.types.ts (96%) diff --git a/packages/manager/apps/pci-instances/src/data/api/instances.ts b/packages/manager/apps/pci-instances/src/data/api/instances.ts index c43af9725337..4b7a634670c0 100644 --- a/packages/manager/apps/pci-instances/src/data/api/instances.ts +++ b/packages/manager/apps/pci-instances/src/data/api/instances.ts @@ -1,5 +1,8 @@ import { v6 } from '@ovh-ux/manager-core-api'; -import { TInstanceDto, TRetrieveInstancesQueryParams } from '@/types/api.types'; +import { + TInstanceDto, + TRetrieveInstancesQueryParams, +} from '@/types/instances/api.types'; export const getInstances = ( projectId: string, diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx index c57ddc211678..314bae216dc5 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx @@ -12,7 +12,7 @@ import { TUseInstancesQueryParams, } from './useInstances'; import { setupInstanceServer } from '@/_mocks_/instances/node'; -import { TInstanceDto, TInstanceStatusDto } from '@/types/api.types'; +import { TInstanceDto, TInstanceStatusDto } from '@/types/instances/api.types'; // builders const instanceDtoBuilder = ( diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts index 5be68ee41f1a..e729c1134d27 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts @@ -11,7 +11,7 @@ import { FilterWithLabel } from '@ovh-ux/manager-react-components/src/components import { getInstances } from '@/data/api/instances'; import { instancesQueryKey } from '@/utils'; import { DeepReadonly } from '@/types/utils.type'; -import { TInstanceDto, TInstanceStatusDto } from '@/types/api.types'; +import { TInstanceDto, TInstanceStatusDto } from '@/types/instances/api.types'; export type TUseInstancesQueryParams = DeepReadonly<{ limit: number; diff --git a/packages/manager/apps/pci-instances/src/types/api.types.ts b/packages/manager/apps/pci-instances/src/types/instances/api.types.ts similarity index 96% rename from packages/manager/apps/pci-instances/src/types/api.types.ts rename to packages/manager/apps/pci-instances/src/types/instances/api.types.ts index d8d7fd875ddb..d11392fb9b31 100644 --- a/packages/manager/apps/pci-instances/src/types/api.types.ts +++ b/packages/manager/apps/pci-instances/src/types/instances/api.types.ts @@ -1,4 +1,4 @@ -import { DeepReadonly } from './utils.type'; +import { DeepReadonly } from '../utils.type'; export type TInstanceDtoAddressType = 'public' | 'private'; From 91ebb97d5503683fa0475f12f587d15bbde82357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Wed, 25 Sep 2024 10:13:47 +0200 Subject: [PATCH 34/76] feat(pci-instances): add catalog logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../pci-instances/src/data/api/catalog.ts | 7 + .../src/data/hooks/catalog/useCatalog.ts | 130 ++++++++++++++++++ .../src/types/catalog/api.types.ts | 98 +++++++++++++ .../src/types/catalog/entity.types.ts | 57 ++++++++ 4 files changed, 292 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/data/api/catalog.ts create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts create mode 100644 packages/manager/apps/pci-instances/src/types/catalog/api.types.ts create mode 100644 packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts diff --git a/packages/manager/apps/pci-instances/src/data/api/catalog.ts b/packages/manager/apps/pci-instances/src/data/api/catalog.ts new file mode 100644 index 000000000000..82b0a01cc831 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/api/catalog.ts @@ -0,0 +1,7 @@ +import { v6 } from '@ovh-ux/manager-core-api'; +import { TCatalogDto } from '@/types/catalog/api.types'; + +export const getCatalog = (projectId: string): Promise => + v6 + .get(`/cloud/project/${projectId}/catalog/instance`) + .then((response) => response.data); diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts new file mode 100644 index 000000000000..32036f1d5410 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts @@ -0,0 +1,130 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import { instancesQueryKey } from '@/utils'; +import { getCatalog } from '@/data/api/catalog'; +import { + TCatalogDto, + TCpuDto, + TDiskDto, + TMemoryDto, + TModelDto, + TPricingDto, + TSpecificationsDto, +} from '@/types/catalog/api.types'; +import { + TCpu, + TMemory, + TModel, + TModelEntity, + TModelPricing, + TModelSpecification, + TStorage, +} from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; + +export type TModelSelector = (rawData: TCatalogDto) => TModelEntity; + +const getSizeUnit = (size: number) => (size < 1000 ? 'Go' : 'To'); + +const mapModelCpu = (cpu: TCpuDto): TCpu => { + const { type, ...rest } = cpu; + return rest; +}; + +const mapModelMemory = (memory: TMemoryDto): TMemory => ({ + size: memory.size, + unit: getSizeUnit(memory.size), +}); + +const mapModelStorages = ( + storages: DeepReadonly, +): DeepReadonly => + storages.map((storage) => ({ + ...storage, + unit: getSizeUnit(storage.capacity), + })); + +const mapModelSpecifications = ( + specifications: DeepReadonly, +): DeepReadonly<{ specifications: TModelSpecification }> => ({ + specifications: { + memory: mapModelMemory(specifications.memory), + cpu: mapModelCpu(specifications.cpu), + bandwidth: specifications.bandwidth.level, + storage: mapModelStorages(specifications.storage.disks), + }, +}); + +const mapModelPricings = ( + pricings: DeepReadonly, +): DeepReadonly<{ pricings: TModelPricing[] }> => ({ + pricings: pricings + .reduce( + (acc: DeepReadonly, cur: DeepReadonly) => { + if (!acc.length) return [cur]; + + const foundPricingByInterval = acc.find( + (elt) => elt.interval === cur.interval, + ); + + if (!foundPricingByInterval) return [...acc, cur]; + if (foundPricingByInterval.price < cur.price) return acc; + return [ + ...acc.filter((elt) => elt.price !== foundPricingByInterval.price), + cur, + ]; + }, + [] as TPricingDto[], + ) + .map(({ regions, osType, price, ...rest }) => ({ + price: price / 100000000, + ...rest, + })), +}); + +const mapModelsData = ( + models: DeepReadonly, +): DeepReadonly => + models.map( + ({ + category, + name, + isNew, + compatibleLocalzone, + compatibleRegion, + banners, + pricings, + specifications, + }) => ({ + category, + name, + isNew, + compatibleLocalzone, + compatibleRegion, + banners, + ...mapModelPricings(pricings), + ...mapModelSpecifications(specifications), + }), + ); + +export const modelSelector: TModelSelector = (rawData) => ({ + models: { + categories: rawData.categories, + data: mapModelsData(rawData.models), + }, +}); + +export const useCatalog = (projectId: string, select: TModelSelector) => { + const queryKey = useMemo(() => instancesQueryKey(projectId, ['catalog']), [ + projectId, + ]); + const fetchCatalog = useCallback(() => getCatalog(projectId), [projectId]); + + return useQuery({ + queryKey, + retry: false, + queryFn: fetchCatalog, + placeholderData: keepPreviousData, + select: useCallback(select, [select]), + }); +}; diff --git a/packages/manager/apps/pci-instances/src/types/catalog/api.types.ts b/packages/manager/apps/pci-instances/src/types/catalog/api.types.ts new file mode 100644 index 000000000000..efae6ed34b46 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/types/catalog/api.types.ts @@ -0,0 +1,98 @@ +import { DeepReadonly } from '../utils.type'; + +export type TCategoryDto = { + name: string; + isNew: boolean; + isDefault: boolean; +}; + +export type TRegionDto = { + name: string; + country: string; + datacenter: string; + isLocalzone: boolean; + isInMaintenance: boolean; + isActivated: boolean; + category: TCategoryDto; +}; + +export type TPriceIntervalDto = 'hour' | 'month'; +export type TOsTypeDto = 'linux' | 'windows'; + +export type TPricingDto = { + price: number; + regions: string[]; + interval: TPriceIntervalDto; + osType: TOsTypeDto; +}; + +export type TBandWidthDto = { + guaranteed: boolean; + level: number; + unlimited: boolean; +}; + +export type TCpuDto = { + cores: number; + frequency: number; + model: string; + type: string; +}; + +export type TGpuDto = { + memory: TGpuMemoryDto; + model: string; + number: number; +}; + +export type TGpuMemoryDto = TMemoryDto & { + interface: string; +}; + +export type TMemoryDto = { + size: number; +}; + +export type TDiskDto = { + capacity: number; + number: number; + technology: string; +}; + +export type TStorageDto = { + disks: TDiskDto[]; + raid: string; +}; + +export type TVrackDto = { + guaranteed: boolean; + level: number; + unlimited: boolean; +}; + +export type TSpecificationsDto = { + bandwidth: TBandWidthDto; + cpu: TCpuDto; + gpu: TGpuDto; + memory: TMemoryDto; + storage: TStorageDto; + vrack: TVrackDto; +}; + +export type TModelDto = { + category: string; + name: string; + isNew: boolean; + compatibleLocalzone: boolean; + compatibleRegion: boolean; + pricings: TPricingDto[]; + specifications: TSpecificationsDto; + banners: string[]; +}; + +export type TCatalogDto = DeepReadonly<{ + projectId: string; + models: TModelDto[]; + categories: TCategoryDto[]; + regions: TRegionDto[]; +}>; diff --git a/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts b/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts new file mode 100644 index 000000000000..4c193754a565 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts @@ -0,0 +1,57 @@ +import { DeepReadonly } from '../utils.type'; + +export type TModelCategory = { + name: string; + isNew: boolean; + isDefault: boolean; +}; + +export type TPriceInterval = 'hour' | 'month'; + +export type TModelPricing = { + price: number; + interval: TPriceInterval; +}; + +export type TCpu = { + cores: number; + frequency: number; + model: string; +}; + +export type TMemory = { + size: number; + unit: string; +}; + +export type TStorage = { + capacity: number; + number: number; + technology: string; + unit: string; +}; + +export type TModelSpecification = { + memory: TMemory; + cpu: TCpu; + storage: TStorage[]; + bandwidth: number; +}; + +export type TModel = { + category: string; + name: string; + isNew: boolean; + compatibleLocalzone: boolean; + compatibleRegion: boolean; + pricings: TModelPricing[]; + specifications: TModelSpecification; + banners: string[]; +}; + +export type TModelEntity = DeepReadonly<{ + models: { + data: TModel[]; + categories: TModelCategory[]; + }; +}>; From 1d53f8235b74db6b7c6443a6ba8e8eec01440d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Wed, 25 Sep 2024 10:15:49 +0200 Subject: [PATCH 35/76] test(pci-instance): add useCatalog test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../_mocks_/catalog/catalogGenerated1.json | 222 +++++++++++++++ .../_mocks_/catalog/catalogGenerated2.json | 258 ++++++++++++++++++ .../src/_mocks_/catalog/expectedEntity1.json | 124 +++++++++ .../src/_mocks_/catalog/expectedEntity2.json | 124 +++++++++ .../src/_mocks_/catalog/handlers.ts | 11 + .../pci-instances/src/_mocks_/catalog/node.ts | 13 + .../data/hooks/catalog/useCatalog.spec.tsx | 84 ++++++ 7 files changed, 836 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated1.json create mode 100644 packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated2.json create mode 100644 packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity1.json create mode 100644 packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity2.json create mode 100644 packages/manager/apps/pci-instances/src/_mocks_/catalog/handlers.ts create mode 100644 packages/manager/apps/pci-instances/src/_mocks_/catalog/node.ts create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated1.json b/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated1.json new file mode 100644 index 000000000000..78859508c7e0 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated1.json @@ -0,0 +1,222 @@ +{ + "projectId": "8c8c4fd6d4414aa29fc952b00005198664", + "models": [ + { + "category": "compute", + "name": "c2-7", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 9780000, + "interval": "hour", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 3520000000, + "interval": "month", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 6520000000, + "interval": "month", + "osType": "windows" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 18300000, + "interval": "hour", + "osType": "windows" + } + ], + "specifications": { + "bandwidth": { + "guaranteed": true, + "level": 250, + "unlimited": false + }, + "cpu": { + "cores": 2, + "frequency": 3, + "model": "vCore", + "type": "vCore" + }, + "gpu": { + "memory": { + "interface": "", + "size": 0 + }, + "model": "", + "number": 0 + }, + "memory": { + "size": 7 + }, + "storage": { + "disks": [ + { + "capacity": 50, + "number": 0, + "technology": "SSD" + } + ], + "raid": "local" + }, + "vrack": { + "guaranteed": false, + "level": 300, + "unlimited": false + } + }, + "banners": [] + }, + { + "category": "baremetal", + "name": "bm-m1", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "regions": ["BHS1.PREPROD"], + "price": 30000000000, + "interval": "month", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD"], + "price": 85000000, + "interval": "hour", + "osType": "linux" + } + ], + "specifications": { + "bandwidth": { + "guaranteed": true, + "level": 1000, + "unlimited": false + }, + "cpu": { + "cores": 8, + "frequency": 3.7, + "model": "Xeon-E 2288G", + "type": "core" + }, + "gpu": { + "memory": { + "interface": "", + "size": 0 + }, + "model": "", + "number": 0 + }, + "memory": { + "size": 64 + }, + "storage": { + "disks": [ + { + "capacity": 960, + "number": 2, + "technology": "SSD" + } + ], + "raid": "" + }, + "vrack": { + "guaranteed": true, + "level": 2000, + "unlimited": false + } + }, + "banners": [] + } + ], + "categories": [ + { + "name": "compute", + "isNew": false, + "isDefault": false + }, + { + "name": "balanced", + "isNew": false, + "isDefault": true + }, + { + "name": "ram", + "isNew": false, + "isDefault": false + }, + { + "name": "accelerated", + "isNew": false, + "isDefault": false + }, + { + "name": "vps", + "isNew": false, + "isDefault": false + }, + { + "name": "discovery", + "isNew": true, + "isDefault": false + }, + { + "name": "iops", + "isNew": false, + "isDefault": false + }, + { + "name": "baremetal", + "isNew": false, + "isDefault": false + } + ], + "regions": [ + { + "name": "GRA1.PREPROD", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "GRA", + "category": { + "name": "eu", + "isNew": true, + "isDefault": true + } + }, + { + "name": "GS1", + "country": "", + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GS", + "category": { + "name": "eu", + "isNew": true, + "isDefault": true + } + }, + { + "name": "BHS1.PREPROD", + "country": "", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "BHS", + "category": { + "name": "na", + "isNew": false, + "isDefault": false + } + } + ] +} diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated2.json b/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated2.json new file mode 100644 index 000000000000..6a2a18c84a63 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated2.json @@ -0,0 +1,258 @@ +{ + "projectId": "8c8c4fd6d4414aa29fc777752b00005198664", + "models": [ + { + "category": "compute", + "name": "c2-7", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 9780000, + "interval": "hour", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 3520000000, + "interval": "month", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 6520000000, + "interval": "month", + "osType": "windows" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 18300000, + "interval": "hour", + "osType": "windows" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 6780000, + "interval": "hour", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 32520000000, + "interval": "month", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 118300000, + "interval": "hour", + "osType": "windows" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 7780000, + "interval": "hour", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 2520000000, + "interval": "month", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD", "GRA1.PREPROD"], + "price": 117780000, + "interval": "hour", + "osType": "linux" + } + ], + "specifications": { + "bandwidth": { + "guaranteed": true, + "level": 250, + "unlimited": false + }, + "cpu": { + "cores": 2, + "frequency": 3, + "model": "vCore", + "type": "vCore" + }, + "gpu": { + "memory": { + "interface": "", + "size": 0 + }, + "model": "", + "number": 0 + }, + "memory": { + "size": 7 + }, + "storage": { + "disks": [ + { + "capacity": 50, + "number": 0, + "technology": "SSD" + } + ], + "raid": "local" + }, + "vrack": { + "guaranteed": false, + "level": 300, + "unlimited": false + } + }, + "banners": [] + }, + { + "category": "baremetal", + "name": "bm-m1", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "regions": ["BHS1.PREPROD"], + "price": 30000000000, + "interval": "month", + "osType": "linux" + }, + { + "regions": ["BHS1.PREPROD"], + "price": 85000000, + "interval": "hour", + "osType": "linux" + } + ], + "specifications": { + "bandwidth": { + "guaranteed": true, + "level": 1000, + "unlimited": false + }, + "cpu": { + "cores": 8, + "frequency": 3.7, + "model": "Xeon-E 2288G", + "type": "core" + }, + "gpu": { + "memory": { + "interface": "", + "size": 0 + }, + "model": "", + "number": 0 + }, + "memory": { + "size": 1000 + }, + "storage": { + "disks": [ + { + "capacity": 2000, + "number": 2, + "technology": "SSD" + } + ], + "raid": "" + }, + "vrack": { + "guaranteed": true, + "level": 2000, + "unlimited": false + } + }, + "banners": [] + } + ], + "categories": [ + { + "name": "compute", + "isNew": false, + "isDefault": false + }, + { + "name": "balanced", + "isNew": false, + "isDefault": true + }, + { + "name": "ram", + "isNew": false, + "isDefault": false + }, + { + "name": "accelerated", + "isNew": false, + "isDefault": false + }, + { + "name": "vps", + "isNew": false, + "isDefault": false + }, + { + "name": "discovery", + "isNew": true, + "isDefault": false + }, + { + "name": "iops", + "isNew": false, + "isDefault": false + }, + { + "name": "baremetal", + "isNew": false, + "isDefault": false + } + ], + "regions": [ + { + "name": "GRA1.PREPROD", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "GRA", + "category": { + "name": "eu", + "isNew": true, + "isDefault": true + } + }, + { + "name": "GS1", + "country": "", + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GS", + "category": { + "name": "eu", + "isNew": true, + "isDefault": true + } + }, + { + "name": "BHS1.PREPROD", + "country": "", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "BHS", + "category": { + "name": "na", + "isNew": false, + "isDefault": false + } + } + ] +} diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity1.json b/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity1.json new file mode 100644 index 000000000000..e2926b8d8286 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity1.json @@ -0,0 +1,124 @@ +{ + "models": { + "categories": [ + { + "name": "compute", + "isNew": false, + "isDefault": false + }, + { + "name": "balanced", + "isNew": false, + "isDefault": true + }, + { + "name": "ram", + "isNew": false, + "isDefault": false + }, + { + "name": "accelerated", + "isNew": false, + "isDefault": false + }, + { + "name": "vps", + "isNew": false, + "isDefault": false + }, + { + "name": "discovery", + "isNew": true, + "isDefault": false + }, + { + "name": "iops", + "isNew": false, + "isDefault": false + }, + { + "name": "baremetal", + "isNew": false, + "isDefault": false + } + ], + "data": [ + { + "category": "compute", + "name": "c2-7", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "price": 0.0978, + "interval": "hour" + }, + { + "price": 35.2, + "interval": "month" + } + ], + "specifications": { + "bandwidth": 250, + "cpu": { + "cores": 2, + "frequency": 3, + "model": "vCore" + }, + "memory": { + "size": 7, + "unit": "Go" + }, + "storage": [ + { + "capacity": 50, + "number": 0, + "technology": "SSD", + "unit": "Go" + } + ] + }, + "banners": [] + }, + { + "category": "baremetal", + "name": "bm-m1", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "price": 300, + "interval": "month" + }, + { + "price": 0.85, + "interval": "hour" + } + ], + "specifications": { + "bandwidth": 1000, + "cpu": { + "cores": 8, + "frequency": 3.7, + "model": "Xeon-E 2288G" + }, + "memory": { + "size": 64, + "unit": "Go" + }, + "storage": [ + { + "capacity": 960, + "number": 2, + "technology": "SSD", + "unit": "Go" + } + ] + }, + "banners": [] + } + ] + } +} diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity2.json b/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity2.json new file mode 100644 index 000000000000..28005a40bcd8 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity2.json @@ -0,0 +1,124 @@ +{ + "models": { + "categories": [ + { + "name": "compute", + "isNew": false, + "isDefault": false + }, + { + "name": "balanced", + "isNew": false, + "isDefault": true + }, + { + "name": "ram", + "isNew": false, + "isDefault": false + }, + { + "name": "accelerated", + "isNew": false, + "isDefault": false + }, + { + "name": "vps", + "isNew": false, + "isDefault": false + }, + { + "name": "discovery", + "isNew": true, + "isDefault": false + }, + { + "name": "iops", + "isNew": false, + "isDefault": false + }, + { + "name": "baremetal", + "isNew": false, + "isDefault": false + } + ], + "data": [ + { + "category": "compute", + "name": "c2-7", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "price": 0.0678, + "interval": "hour" + }, + { + "price": 25.2, + "interval": "month" + } + ], + "specifications": { + "bandwidth": 250, + "cpu": { + "cores": 2, + "frequency": 3, + "model": "vCore" + }, + "memory": { + "size": 7, + "unit": "Go" + }, + "storage": [ + { + "capacity": 50, + "number": 0, + "technology": "SSD", + "unit": "Go" + } + ] + }, + "banners": [] + }, + { + "category": "baremetal", + "name": "bm-m1", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "price": 300, + "interval": "month" + }, + { + "price": 0.85, + "interval": "hour" + } + ], + "specifications": { + "bandwidth": 1000, + "cpu": { + "cores": 8, + "frequency": 3.7, + "model": "Xeon-E 2288G" + }, + "memory": { + "size": 1000, + "unit": "To" + }, + "storage": [ + { + "capacity": 2000, + "number": 2, + "technology": "SSD", + "unit": "To" + } + ] + }, + "banners": [] + } + ] + } +} diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/handlers.ts b/packages/manager/apps/pci-instances/src/_mocks_/catalog/handlers.ts new file mode 100644 index 000000000000..0361adfb8e80 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/_mocks_/catalog/handlers.ts @@ -0,0 +1,11 @@ +import { http, HttpResponse, JsonBodyType, RequestHandler } from 'msw'; + +export const catalogHandlers = ( + mockedResponsePayload?: T, +): RequestHandler[] => [ + http.get('*/cloud/project/:projectId/catalog/instance', async () => { + return !mockedResponsePayload + ? new HttpResponse(null, { status: 500 }) + : HttpResponse.json(mockedResponsePayload); + }), +]; diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/node.ts b/packages/manager/apps/pci-instances/src/_mocks_/catalog/node.ts new file mode 100644 index 000000000000..841c2ecc39db --- /dev/null +++ b/packages/manager/apps/pci-instances/src/_mocks_/catalog/node.ts @@ -0,0 +1,13 @@ +import { setupServer } from 'msw/node'; +import { JsonBodyType } from 'msw'; +import { catalogHandlers } from './handlers'; + +export const setupCatalogServer = ( + mockedResponsePayload?: T, +) => { + const server = setupServer(...catalogHandlers(mockedResponsePayload)); + server.listen({ + onUnhandledRequest: 'error', + }); + return server; +}; diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx new file mode 100644 index 000000000000..fb1f3d3feefc --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx @@ -0,0 +1,84 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { FC, PropsWithChildren } from 'react'; +import { describe, expect, test, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { SetupServer } from 'msw/lib/node'; +import { AxiosError } from 'axios'; +import { modelSelector, TModelSelector, useCatalog } from './useCatalog'; +import { TModelEntity } from '@/types/catalog/entity.types'; +import { setupCatalogServer } from '@/_mocks_/catalog/node'; +import { instancesQueryKey } from '@/utils'; +import mockedCatalog1 from '@/_mocks_/catalog/catalogGenerated1.json'; +import mockedCatalog2 from '@/_mocks_/catalog/catalogGenerated2.json'; +import { TCatalogDto } from '@/types/catalog/api.types'; +import expectedEntity1 from '@/_mocks_/catalog/expectedEntity1.json'; +import expectedEntity2 from '@/_mocks_/catalog/expectedEntity2.json'; + +// initializers +const initQueryClient = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const wrapper: FC = ({ children }) => ( + {children} + ); + return { wrapper, queryClient }; +}; + +// test data +type Data = { + projectId: string; + selector: TModelSelector; + queryPayload: TCatalogDto; + expectedModelEntity: TModelEntity; +}; + +const fakeProjectId = 'p42b4f068f404ef3832435304a316332'; + +// msw server +let server: SetupServer; + +describe('useCatalog hook', () => { + describe.each` + projectId | queryPayload | selector | expectedModelEntity + ${fakeProjectId} | ${undefined} | ${modelSelector} | ${undefined} + ${fakeProjectId} | ${{ models: [], categories: [] }} | ${modelSelector} | ${{ models: { data: [], categories: [] } }} + ${fakeProjectId} | ${mockedCatalog1} | ${modelSelector} | ${expectedEntity1} + ${fakeProjectId} | ${mockedCatalog2} | ${modelSelector} | ${expectedEntity2} + `( + 'Given a projectId <$projectId> and a selector <$selector>', + ({ projectId, selector, queryPayload, expectedModelEntity }: Data) => { + afterEach(() => { + server?.close(); + }); + test(`When invoking useCatalog() hook', then, expect the computed model entity to be '${JSON.stringify( + expectedModelEntity, + )}'`, async () => { + server = setupCatalogServer(queryPayload); + + const { wrapper, queryClient } = initQueryClient(); + const { result } = renderHook(() => useCatalog(projectId, selector), { + wrapper, + }); + const queryCache = queryClient.getQueryCache(); + + expect(result.current.isPending).toBe(true); + if (queryPayload) { + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + } else { + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toHaveProperty('response.status', 500); + expect(result.current.error).instanceOf(AxiosError); + } + expect(result.current.data).toStrictEqual(expectedModelEntity); + expect( + queryCache.getAll().map((cache) => cache.queryKey)[0], + ).toStrictEqual(instancesQueryKey(projectId, ['catalog'])); + }); + }, + ); +}); From 997c8aac3f96fca2fabab244e4213d8bd5a50cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 18:36:20 +0200 Subject: [PATCH 36/76] feat(pci-instances): update packages & config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- packages/manager/apps/pci-instances/package.json | 2 ++ .../manager/apps/pci-instances/setup.vitest.ts | 4 ++++ .../apps/pci-instances/tailwind.config.js | 1 + .../manager/apps/pci-instances/tsconfig.json | 8 +++++++- .../manager/apps/pci-instances/vitest.config.js | 16 +++++++++------- yarn.lock | 5 +++++ 6 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 packages/manager/apps/pci-instances/setup.vitest.ts diff --git a/packages/manager/apps/pci-instances/package.json b/packages/manager/apps/pci-instances/package.json index 3db6730a23a6..7ad7124dd14a 100644 --- a/packages/manager/apps/pci-instances/package.json +++ b/packages/manager/apps/pci-instances/package.json @@ -30,8 +30,10 @@ "@ovhcloud/ods-theme-blue-jeans": "17.2.2", "@tanstack/react-query": "^5.51.21", "axios": "^1.6.8", + "clsx": "^2.1.1", "element-internals-polyfill": "^1.3.10", "i18next": "^23.8.2", + "immer": "^10.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.0.5", diff --git a/packages/manager/apps/pci-instances/setup.vitest.ts b/packages/manager/apps/pci-instances/setup.vitest.ts new file mode 100644 index 000000000000..f788f7050d2a --- /dev/null +++ b/packages/manager/apps/pci-instances/setup.vitest.ts @@ -0,0 +1,4 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +vi.mock('zustand'); diff --git a/packages/manager/apps/pci-instances/tailwind.config.js b/packages/manager/apps/pci-instances/tailwind.config.js index 14998255815f..ad0d9bdeb306 100644 --- a/packages/manager/apps/pci-instances/tailwind.config.js +++ b/packages/manager/apps/pci-instances/tailwind.config.js @@ -6,6 +6,7 @@ module.exports = { content: [ './src/**/*.{js,jsx,ts,tsx}', '../../../manager-react-components/src/**/*.{js,jsx,ts,tsx}', + '../../modules/manager-pci-common/**/*.{js,jsx,ts,tsx}', ], corePlugins: { preflight: false, diff --git a/packages/manager/apps/pci-instances/tsconfig.json b/packages/manager/apps/pci-instances/tsconfig.json index 831d6f78cb58..4c9339a6e707 100644 --- a/packages/manager/apps/pci-instances/tsconfig.json +++ b/packages/manager/apps/pci-instances/tsconfig.json @@ -21,5 +21,11 @@ "esModuleInterop": true }, "include": ["src"], - "exclude": ["node_modules", "public", "types", "src/**/*.spec.ts"] + "exclude": [ + "node_modules", + "public", + "types", + "src/**/*.spec.ts", + "__mocks__" + ] } diff --git a/packages/manager/apps/pci-instances/vitest.config.js b/packages/manager/apps/pci-instances/vitest.config.js index 25a685f80d02..75826c50fc41 100644 --- a/packages/manager/apps/pci-instances/vitest.config.js +++ b/packages/manager/apps/pci-instances/vitest.config.js @@ -9,16 +9,17 @@ export default defineConfig({ globals: true, environment: 'jsdom', coverage: { - include: ['src'], exclude: [ - 'src/vite-*.ts', - 'src/App.tsx', - 'src/core/ShellRoutingSync.tsx', - 'src/main.tsx', - 'src/routes.tsx', - 'src/_mocks_', + 'vite-*.ts', + 'App.tsx', + 'core/ShellRoutingSync.tsx', + 'main.tsx', + 'routes.tsx', + '__mocks__', + 'queryClient.ts', ], }, + setupFiles: ['./setup.vitest.ts'], }, resolve: { alias: { @@ -26,4 +27,5 @@ export default defineConfig({ }, mainFields: ['module'], }, + root: './src', }); diff --git a/yarn.lock b/yarn.lock index bc699c3723f5..836a6fa60a48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17761,6 +17761,11 @@ image-size@~0.5.0: resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ== +immer@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + immutable@^4.0.0: version "4.3.5" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0" From ff4b65aac815a29a271b628bdedc63196066c618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 20:48:35 +0200 Subject: [PATCH 37/76] feat(pci-instances): update useCatalog logic & types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- .../src/data/hooks/catalog/useCatalog.ts | 35 +++++++++++++++---- .../src/types/catalog/entity.types.ts | 8 ++++- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts index 32036f1d5410..098b1b29bb9b 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts @@ -6,6 +6,7 @@ import { TCatalogDto, TCpuDto, TDiskDto, + TGpuDto, TMemoryDto, TModelDto, TPricingDto, @@ -13,6 +14,7 @@ import { } from '@/types/catalog/api.types'; import { TCpu, + TGpu, TMemory, TModel, TModelEntity, @@ -36,12 +38,17 @@ const mapModelMemory = (memory: TMemoryDto): TMemory => ({ unit: getSizeUnit(memory.size), }); +const mapModelGpu = ({ model, number }: TGpuDto): TGpu => ({ + model, + number, +}); + const mapModelStorages = ( storages: DeepReadonly, ): DeepReadonly => storages.map((storage) => ({ ...storage, - unit: getSizeUnit(storage.capacity), + sizeUnit: getSizeUnit(storage.capacity), })); const mapModelSpecifications = ( @@ -52,6 +59,7 @@ const mapModelSpecifications = ( cpu: mapModelCpu(specifications.cpu), bandwidth: specifications.bandwidth.level, storage: mapModelStorages(specifications.storage.disks), + gpu: mapModelGpu(specifications.gpu), }, }); @@ -76,10 +84,7 @@ const mapModelPricings = ( }, [] as TPricingDto[], ) - .map(({ regions, osType, price, ...rest }) => ({ - price: price / 100000000, - ...rest, - })), + .map(({ regions, osType, ...rest }) => rest), }); const mapModelsData = ( @@ -107,10 +112,25 @@ const mapModelsData = ( }), ); +const sortModels = (models: DeepReadonly) => + models + .slice() + .sort((a, b) => { + const aGroup = Number((a.name.match(/[0-9]+/) || [])[0]); + const bGroup = Number((b.name.match(/[0-9]+/) || [])[0]); + const aRank = Number((a.name.match(/-([^-]+)$/) || [])[1]); + const bRank = Number((b.name.match(/-([^-]+)$/) || [])[1]); + return aGroup === bGroup ? aRank - bRank : bGroup - aGroup; + }) + .sort( + (a, b) => Number(b.compatibleLocalzone) - Number(a.compatibleLocalzone), + ) + .sort((a, b) => Number(b.isNew) - Number(a.isNew)); + export const modelSelector: TModelSelector = (rawData) => ({ models: { categories: rawData.categories, - data: mapModelsData(rawData.models), + data: sortModels(mapModelsData(rawData.models)), }, }); @@ -123,6 +143,9 @@ export const useCatalog = (projectId: string, select: TModelSelector) => { return useQuery({ queryKey, retry: false, + staleTime: Infinity, + gcTime: Infinity, + refetchOnWindowFocus: false, queryFn: fetchCatalog, placeholderData: keepPreviousData, select: useCallback(select, [select]), diff --git a/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts b/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts index 4c193754a565..f4e2213e7573 100644 --- a/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts +++ b/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts @@ -19,6 +19,11 @@ export type TCpu = { model: string; }; +export type TGpu = { + number: number; + model: string; +}; + export type TMemory = { size: number; unit: string; @@ -28,7 +33,7 @@ export type TStorage = { capacity: number; number: number; technology: string; - unit: string; + sizeUnit: string; }; export type TModelSpecification = { @@ -36,6 +41,7 @@ export type TModelSpecification = { cpu: TCpu; storage: TStorage[]; bandwidth: number; + gpu: TGpu; }; export type TModel = { From 3c794354507a87b17c5e13cad67dbbd8f1f75b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 20:54:47 +0200 Subject: [PATCH 38/76] test(pci-instances): update test suites & mock folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- .../catalog/catalogGenerated1.json | 4 ++-- .../catalog/catalogGenerated2.json | 4 ++-- .../catalog/expectedEntity1.json | 24 ++++++++++++------- .../catalog/expectedEntity2.json | 24 ++++++++++++------- .../src/__mocks__/catalog/handlers.ts | 19 +++++++++++++++ .../{_mocks_ => __mocks__}/catalog/node.ts | 0 .../instances/handlers.ts | 0 .../{_mocks_ => __mocks__}/instances/node.ts | 0 .../src/_mocks_/catalog/handlers.ts | 11 --------- .../data/hooks/catalog/useCatalog.spec.tsx | 10 ++++---- .../hooks/instances/useInstances.spec.tsx | 2 +- 11 files changed, 61 insertions(+), 37 deletions(-) rename packages/manager/apps/pci-instances/src/{_mocks_ => __mocks__}/catalog/catalogGenerated1.json (98%) rename packages/manager/apps/pci-instances/src/{_mocks_ => __mocks__}/catalog/catalogGenerated2.json (99%) rename packages/manager/apps/pci-instances/src/{_mocks_ => __mocks__}/catalog/expectedEntity1.json (85%) rename packages/manager/apps/pci-instances/src/{_mocks_ => __mocks__}/catalog/expectedEntity2.json (85%) create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/catalog/handlers.ts rename packages/manager/apps/pci-instances/src/{_mocks_ => __mocks__}/catalog/node.ts (100%) rename packages/manager/apps/pci-instances/src/{_mocks_ => __mocks__}/instances/handlers.ts (100%) rename packages/manager/apps/pci-instances/src/{_mocks_ => __mocks__}/instances/node.ts (100%) delete mode 100644 packages/manager/apps/pci-instances/src/_mocks_/catalog/handlers.ts diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated1.json b/packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated1.json similarity index 98% rename from packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated1.json rename to packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated1.json index 78859508c7e0..ff0790352f9e 100644 --- a/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated1.json +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated1.json @@ -50,8 +50,8 @@ "interface": "", "size": 0 }, - "model": "", - "number": 0 + "model": "Tesla V100S", + "number": 4 }, "memory": { "size": 7 diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated2.json b/packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated2.json similarity index 99% rename from packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated2.json rename to packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated2.json index 6a2a18c84a63..57f7183cfd96 100644 --- a/packages/manager/apps/pci-instances/src/_mocks_/catalog/catalogGenerated2.json +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated2.json @@ -86,8 +86,8 @@ "interface": "", "size": 0 }, - "model": "", - "number": 0 + "model": "Tesla V100S", + "number": 4 }, "memory": { "size": 7 diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity1.json b/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity1.json similarity index 85% rename from packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity1.json rename to packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity1.json index e2926b8d8286..3478fb664ce5 100644 --- a/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity1.json +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity1.json @@ -51,11 +51,11 @@ "compatibleRegion": true, "pricings": [ { - "price": 0.0978, + "price": 9780000, "interval": "hour" }, { - "price": 35.2, + "price": 3520000000, "interval": "month" } ], @@ -75,9 +75,13 @@ "capacity": 50, "number": 0, "technology": "SSD", - "unit": "Go" + "sizeUnit": "Go" } - ] + ], + "gpu": { + "model": "Tesla V100S", + "number": 4 + } }, "banners": [] }, @@ -89,11 +93,11 @@ "compatibleRegion": true, "pricings": [ { - "price": 300, + "price": 30000000000, "interval": "month" }, { - "price": 0.85, + "price": 85000000, "interval": "hour" } ], @@ -113,9 +117,13 @@ "capacity": 960, "number": 2, "technology": "SSD", - "unit": "Go" + "sizeUnit": "Go" } - ] + ], + "gpu": { + "model": "", + "number": 0 + } }, "banners": [] } diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity2.json b/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity2.json similarity index 85% rename from packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity2.json rename to packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity2.json index 28005a40bcd8..8cdbfd519aa5 100644 --- a/packages/manager/apps/pci-instances/src/_mocks_/catalog/expectedEntity2.json +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity2.json @@ -51,11 +51,11 @@ "compatibleRegion": true, "pricings": [ { - "price": 0.0678, + "price": 6780000, "interval": "hour" }, { - "price": 25.2, + "price": 2520000000, "interval": "month" } ], @@ -75,9 +75,13 @@ "capacity": 50, "number": 0, "technology": "SSD", - "unit": "Go" + "sizeUnit": "Go" } - ] + ], + "gpu": { + "model": "Tesla V100S", + "number": 4 + } }, "banners": [] }, @@ -89,11 +93,11 @@ "compatibleRegion": true, "pricings": [ { - "price": 300, + "price": 30000000000, "interval": "month" }, { - "price": 0.85, + "price": 85000000, "interval": "hour" } ], @@ -113,9 +117,13 @@ "capacity": 2000, "number": 2, "technology": "SSD", - "unit": "To" + "sizeUnit": "To" } - ] + ], + "gpu": { + "model": "", + "number": 0 + } }, "banners": [] } diff --git a/packages/manager/apps/pci-instances/src/__mocks__/catalog/handlers.ts b/packages/manager/apps/pci-instances/src/__mocks__/catalog/handlers.ts new file mode 100644 index 000000000000..a329da3233fd --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/handlers.ts @@ -0,0 +1,19 @@ +import { http, HttpResponse, JsonBodyType, RequestHandler, delay } from 'msw'; +import mockedCatalog from './fullCatalogGenerated.json'; + +export const catalogHandlers = ( + mockedResponsePayload?: T, +): RequestHandler[] => [ + http.get('*/cloud/project/:projectId/catalog/instance', async () => { + return !mockedResponsePayload + ? new HttpResponse(null, { status: 500 }) + : HttpResponse.json(mockedResponsePayload); + }), +]; + +export const browserHandlers: RequestHandler[] = [ + http.get('*/cloud/project/:projectId/catalog/instance', async () => { + await delay(2000); + return HttpResponse.json(mockedCatalog); + }), +]; diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/node.ts b/packages/manager/apps/pci-instances/src/__mocks__/catalog/node.ts similarity index 100% rename from packages/manager/apps/pci-instances/src/_mocks_/catalog/node.ts rename to packages/manager/apps/pci-instances/src/__mocks__/catalog/node.ts diff --git a/packages/manager/apps/pci-instances/src/_mocks_/instances/handlers.ts b/packages/manager/apps/pci-instances/src/__mocks__/instances/handlers.ts similarity index 100% rename from packages/manager/apps/pci-instances/src/_mocks_/instances/handlers.ts rename to packages/manager/apps/pci-instances/src/__mocks__/instances/handlers.ts diff --git a/packages/manager/apps/pci-instances/src/_mocks_/instances/node.ts b/packages/manager/apps/pci-instances/src/__mocks__/instances/node.ts similarity index 100% rename from packages/manager/apps/pci-instances/src/_mocks_/instances/node.ts rename to packages/manager/apps/pci-instances/src/__mocks__/instances/node.ts diff --git a/packages/manager/apps/pci-instances/src/_mocks_/catalog/handlers.ts b/packages/manager/apps/pci-instances/src/_mocks_/catalog/handlers.ts deleted file mode 100644 index 0361adfb8e80..000000000000 --- a/packages/manager/apps/pci-instances/src/_mocks_/catalog/handlers.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { http, HttpResponse, JsonBodyType, RequestHandler } from 'msw'; - -export const catalogHandlers = ( - mockedResponsePayload?: T, -): RequestHandler[] => [ - http.get('*/cloud/project/:projectId/catalog/instance', async () => { - return !mockedResponsePayload - ? new HttpResponse(null, { status: 500 }) - : HttpResponse.json(mockedResponsePayload); - }), -]; diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx index fb1f3d3feefc..8265b5d8569b 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx @@ -6,13 +6,13 @@ import { SetupServer } from 'msw/lib/node'; import { AxiosError } from 'axios'; import { modelSelector, TModelSelector, useCatalog } from './useCatalog'; import { TModelEntity } from '@/types/catalog/entity.types'; -import { setupCatalogServer } from '@/_mocks_/catalog/node'; +import { setupCatalogServer } from '@/__mocks__/catalog/node'; import { instancesQueryKey } from '@/utils'; -import mockedCatalog1 from '@/_mocks_/catalog/catalogGenerated1.json'; -import mockedCatalog2 from '@/_mocks_/catalog/catalogGenerated2.json'; +import mockedCatalog1 from '@/__mocks__/catalog/catalogGenerated1.json'; +import mockedCatalog2 from '@/__mocks__/catalog/catalogGenerated2.json'; import { TCatalogDto } from '@/types/catalog/api.types'; -import expectedEntity1 from '@/_mocks_/catalog/expectedEntity1.json'; -import expectedEntity2 from '@/_mocks_/catalog/expectedEntity2.json'; +import expectedEntity1 from '@/__mocks__/catalog/expectedEntity1.json'; +import expectedEntity2 from '@/__mocks__/catalog/expectedEntity2.json'; // initializers const initQueryClient = () => { diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx index 314bae216dc5..e0091392a785 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx @@ -11,7 +11,7 @@ import { useInstances, TUseInstancesQueryParams, } from './useInstances'; -import { setupInstanceServer } from '@/_mocks_/instances/node'; +import { setupInstanceServer } from '@/__mocks__/instances/node'; import { TInstanceDto, TInstanceStatusDto } from '@/types/instances/api.types'; // builders From 6b89958a7ce1223aaf453ca02db885971e28c309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 20:57:36 +0200 Subject: [PATCH 39/76] test(pci-instances): declare mock functions for zustand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- .../pci-instances/src/__mocks__/zustand.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/zustand.ts diff --git a/packages/manager/apps/pci-instances/src/__mocks__/zustand.ts b/packages/manager/apps/pci-instances/src/__mocks__/zustand.ts new file mode 100644 index 000000000000..e0842711bb3c --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/zustand.ts @@ -0,0 +1,55 @@ +import * as zustand from 'zustand'; +import { act } from '@testing-library/react'; +import { vi } from 'vitest'; + +/** + * Zustand mock functions from https://zustand.docs.pmnd.rs/guides/testing#vitest + * These lines allow to: + * - run tests that use 'createStore' curried function + * - clear stores after each test + * Warning: to work with vitest, this file must be under __mocks__ folder in the root project (.) + * */ +const { + create: actualCreate, + createStore: actualCreateStore, +} = await vi.importActual('zustand'); + +export const storeResetFns = new Set<() => void>(); + +const createUncurried = (stateCreator: zustand.StateCreator) => { + const store = actualCreate(stateCreator); + const initialState = store.getInitialState(); + storeResetFns.add(() => { + store.setState(initialState, true); + }); + return store; +}; + +export const create = ((stateCreator: zustand.StateCreator) => { + return typeof stateCreator === 'function' + ? createUncurried(stateCreator) + : createUncurried; +}) as typeof zustand.create; + +const createStoreUncurried = (stateCreator: zustand.StateCreator) => { + const store = actualCreateStore(stateCreator); + const initialState = store.getInitialState(); + storeResetFns.add(() => { + store.setState(initialState, true); + }); + return store; +}; + +export const createStore = ((stateCreator: zustand.StateCreator) => { + return typeof stateCreator === 'function' + ? createStoreUncurried(stateCreator) + : createStoreUncurried; +}) as typeof zustand.createStore; + +afterEach(() => { + act(() => { + storeResetFns.forEach((resetFn) => { + resetFn(); + }); + }); +}); From 8526ceb73be2018b0a882f49f0f8cbd1f5f52803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 21:50:43 +0200 Subject: [PATCH 40/76] feat(pci-instances): add ModelStep component & use it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- .../components/layout/Layout.component.tsx | 2 +- .../instances/create/CreateInstance.page.tsx | 40 ++-- .../create/steps/ModelsStep.component.tsx | 218 ++++++++++++++++++ 3 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx diff --git a/packages/manager/apps/pci-instances/src/components/layout/Layout.component.tsx b/packages/manager/apps/pci-instances/src/components/layout/Layout.component.tsx index 495d26235942..a7d8b25f0bca 100644 --- a/packages/manager/apps/pci-instances/src/components/layout/Layout.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/layout/Layout.component.tsx @@ -10,7 +10,7 @@ const Layout: FC = () => { useHidePreloader(); useShellRoutingSync(); return ( -
+
diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx index 2890cea11153..953a00c1d374 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx @@ -1,21 +1,25 @@ -import { - PageLayout, - StepComponent, - Title, -} from '@ovh-ux/manager-react-components'; +import { PageLayout, Title } from '@ovh-ux/manager-react-components'; import { FC, useMemo } from 'react'; -import { useRouteLoaderData } from 'react-router-dom'; +import { useHref, useRouteLoaderData } from 'react-router-dom'; import { TProject } from '@ovh-ux/manager-pci-common'; import { useTranslation } from 'react-i18next'; -import { OsdsDivider } from '@ovhcloud/ods-components/react'; +import { + OsdsDivider, + OsdsIcon, + OsdsLink, +} from '@ovhcloud/ods-components/react'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { ODS_ICON_NAME, ODS_ICON_SIZE } from '@ovhcloud/ods-components'; import { Breadcrumb, TBreadcrumbProps, } from '@/components/breadcrumb/Breadcrumb.component'; import { useHidePreloader } from '@/hooks/hidePreloader/useHidePreloader'; +import { ModelsStep } from './steps/ModelsStep.component'; const CreateInstance: FC = () => { const project = useRouteLoaderData('root') as TProject; + const backHref = useHref('..'); const { t } = useTranslation(['create', 'common']); const breadcrumbItems = useMemo( () => [ @@ -36,18 +40,26 @@ const CreateInstance: FC = () => { items={breadcrumbItems} /> )} + + + {t('go_back')} +
{t('common:create_instance')}
- +
); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx new file mode 100644 index 000000000000..92114e8cf416 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx @@ -0,0 +1,218 @@ +import { StepComponent, TabsComponent } from '@ovh-ux/manager-react-components'; +import clsx from 'clsx'; +import { FC, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { useShallow } from 'zustand/react/shallow'; +import { + ODS_THEME_COLOR_INTENT, + ODS_THEME_TYPOGRAPHY_SIZE, +} from '@ovhcloud/ods-common-theming'; +import { OsdsChip, OsdsText } from '@ovhcloud/ods-components/react'; +import { FlavorTile } from '@ovh-ux/manager-pci-common/src/components/flavor-selector/FlavorTile.component'; +import { + ODS_CHIP_SIZE, + ODS_TEXT_LEVEL, + ODS_TEXT_SIZE, +} from '@ovhcloud/ods-components'; +import { modelSelector, useCatalog } from '@/data/hooks/catalog/useCatalog'; +import { Spinner } from '@/components/spinner/Spinner.component'; +import { + TModelCategory, + TModelPricing, + TPriceInterval, + TStorage, +} from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; +import '@ovh-ux/manager-pci-common/src/components/flavor-selector/translations/index'; +import { useAppStore } from '@/store/hooks/useAppStore'; +import { TStep, TStepId } from '@/store/slices/stepper.slice'; + +const modelStepId: TStepId = 'model'; +const validatedStepState: TStep = { + isOpen: true, + isChecked: true, + isLocked: true, +}; +const editedStepState: TStep = { + isOpen: true, + isChecked: false, + isLocked: false, +}; + +export const ModelsStep: FC = () => { + const { projectId } = useParams() as { projectId: string }; + const { t } = useTranslation(['create', 'stepper']); + const { data, isLoading } = useCatalog(projectId, modelSelector); + const { stepState, modelName, setModelName, updateStep } = useAppStore( + useShallow((state) => ({ + stepState: state.stepState(), + modelName: state.modelName(), + setModelName: state.setModelName, + updateStep: state.updateStep, + })), + ); + + const getModelPrice = useCallback( + ( + pricings: DeepReadonly, + key: TPriceInterval, + ): number | undefined => + pricings.find((price) => price.interval === key)?.price, + [], + ); + + const handleModelTileClick = useCallback( + (name: string) => () => setModelName(name), + [setModelName], + ); + + const handleNextStep = useCallback( + (id: string) => { + updateStep(id as TStepId, validatedStepState); + }, + [updateStep], + ); + + const handleEditStep = useCallback( + (id: string) => { + updateStep(id as TStepId, editedStepState); + }, + [updateStep], + ); + + const currentStepState = useMemo(() => stepState(modelStepId), [stepState]); + + return ( +
+ + <> + {isLoading && } + {data && ( + > + items={data.models.categories as TModelCategory[]} + className="[&:last-child>li]:py-0" + itemKey={({ name }) => name} + titleElement={(category, isSelected) => ( + +
+ + {t(`model_category_${category.name}_title`)} + + {category.isNew && ( + + + {t('new')} + + + )} +
+
+ )} + contentElement={(category) => ( +
+ + {t(`model_category_${category.name}_description`)} + +
+ {data.models.data + .filter((model) => model.category === category.name) + .map( + ({ + name, + specifications, + compatibleLocalzone, + compatibleRegion, + isNew, + pricings, + }) => { + const monthlyPrice = getModelPrice(pricings, 'month'); + const hourlyPrice = + getModelPrice(pricings, 'hour') ?? 0; + return ( +
+ +
+ ); + }, + )} +
+
+ )} + /> + )} + +
+
+ ); +}; From 7f8678980b6986bcdaeae6e076e943fccd6eff0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 21:55:45 +0200 Subject: [PATCH 41/76] feat(pci-instances): create useAppStore with slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- .../src/store/hooks/useAppStore.ts | 19 ++++++ .../src/store/slices/form.slice.ts | 37 +++++++++++ .../src/store/slices/stepper.slice.ts | 66 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/store/hooks/useAppStore.ts create mode 100644 packages/manager/apps/pci-instances/src/store/slices/form.slice.ts create mode 100644 packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts diff --git a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.ts b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.ts new file mode 100644 index 000000000000..599f408389ce --- /dev/null +++ b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; +import { devtools } from 'zustand/middleware'; +import { createStepperSlice, TStepperSlice } from '../slices/stepper.slice'; +import { createFormSlice, TFormSlice } from '../slices/form.slice'; + +export const useAppStore = create()( + devtools( + immer((...a) => ({ + ...createStepperSlice(...a), + ...createFormSlice(...a), + })), + { + anonymousActionType: 'Store', + name: 'appStore', + enabled: process.env.NODE_ENV === 'development', + }, + ), +); diff --git a/packages/manager/apps/pci-instances/src/store/slices/form.slice.ts b/packages/manager/apps/pci-instances/src/store/slices/form.slice.ts new file mode 100644 index 000000000000..b2da0aab74bd --- /dev/null +++ b/packages/manager/apps/pci-instances/src/store/slices/form.slice.ts @@ -0,0 +1,37 @@ +import { StateCreator } from 'zustand'; + +export type TForm = { + modelName: string | null; +}; + +export type TState = { + form: TForm; +}; + +// Computed +export type TQuery = { + modelName: () => string | null; +}; + +// Handlers +export type TCommand = { + setModelName: (newName: string) => void; +}; + +export type TFormSlice = TState & TCommand & TQuery; + +const intialForm: TForm = { + modelName: null, +}; + +export const createFormSlice: StateCreator = ( + set, + get, +) => ({ + form: intialForm, + setModelName: (newName) => + set((state) => ({ + form: { ...state.form, modelName: newName }, + })), + modelName: () => get().form.modelName, +}); diff --git a/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts b/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts new file mode 100644 index 000000000000..348b54c1162f --- /dev/null +++ b/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts @@ -0,0 +1,66 @@ +import { StateCreator } from 'zustand'; +import { enableMapSet } from 'immer'; + +enableMapSet(); + +export type TStepId = 'model'; + +export type TStep = { + isOpen: boolean; + isChecked: boolean; + isLocked: boolean; +}; + +export type TSteps = Map; + +export type TState = { + steps: TSteps; +}; + +/** + * By default, computed functions in Zustand do not return updated values. + * If used with a parameter, currification enables reactivity for getters. + */ +export type TQuery = { + stepState: () => (stepId: TStepId) => TStep | undefined; +}; + +// Handlers +export type TCommand = { + updateStep: (stepId: TStepId, step: Partial) => void; +}; + +export type TStepperSlice = TQuery & TState & TCommand; + +const initStep = (isOpen: boolean): TStep => ({ + isOpen, + isChecked: false, + isLocked: false, +}); + +const initialSteps = new Map([['model', initStep(true)]]); + +export const createStepperSlice: StateCreator< + TStepperSlice, + [], + [], + TStepperSlice +> = (set, get) => ({ + steps: initialSteps, + stepState: () => (stepId) => get().steps.get(stepId), + updateStep: (stepId, step) => + set((state) => { + const newSteps = new Map(state.steps); + const activeStep = newSteps.get(stepId); + if (activeStep) { + const updatedSteps = newSteps.set(stepId, { + ...activeStep, + ...(step.isOpen !== undefined && { isOpen: step.isOpen }), + ...(step.isChecked !== undefined && { isChecked: step.isChecked }), + ...(step.isLocked !== undefined && { isLocked: step.isLocked }), + }); + return { steps: updatedSteps }; + } + return { steps: newSteps }; + }), +}); From ef491a127d9a2d9cb1194d21dedc5c5fc4fb3ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 21:58:06 +0200 Subject: [PATCH 42/76] feat(pci-instances): update translations files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- .../translations/create/Messages_fr_FR.json | 18 +++++++++++++++++- .../order-price/Messages_fr_FR.json | 8 ++++++++ .../translations/stepper/Messages_fr_FR.json | 4 ++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 packages/manager/apps/pci-instances/public/translations/order-price/Messages_fr_FR.json create mode 100644 packages/manager/apps/pci-instances/public/translations/stepper/Messages_fr_FR.json diff --git a/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json index e2da3634afce..54c10222cbf9 100644 --- a/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json @@ -1,3 +1,19 @@ { - "select_template": "Sélectionnez un modèle" + "select_template": "Sélectionnez un modèle", + "new": "Nouveau", + "go_back": "Revenir à la page précédente", + "model_category_balanced_title": "General Purpose", + "model_category_compute_title": "Compute Optimized", + "model_category_ram_title": "Memory Optimized", + "model_category_accelerated_title": "GPU", + "model_category_discovery_title": "Discovery", + "model_category_iops_title": "Storage Optimized", + "model_category_baremetal_title": "Metal", + "model_category_balanced_description": "Les instances à usage général offrent un équilibre entre RAM et performances.", + "model_category_compute_description": "Les instances de calcul optimisé sont idéales pour les applications nécessitant des fréquences de calculs importantes ou de la parallélisation de tâches.", + "model_category_ram_description": "Les instances à mémoire optimisée sont recommandées pour vos bases de données, analyses et calculs en mémoire, ainsi que d'autres applicatifs gourmands en RAM.", + "model_category_accelerated_description": "Les instances de calcul accéléré (GPU, FPGA) sont jusqu'à 1 000 fois plus rapides qu'un CPU sur certaines applications (rendering, transcodage vidéo, bio-informatique, Big Data, deep learning, etc.)", + "model_category_discovery_description": "Les instances à ressources partagées (Discovery) sont adaptées aux tests, recettes et environnements de développement. Leurs performances peuvent légèrement varier au cours du temps.", + "model_category_iops_description": "Les instances IOPS livrent les transactions disque les plus rapides de la gamme Public Cloud.", + "model_category_baremetal_description": "Les instances metal proposent des serveurs physiques à la demande, livrés en quelques minutes et facturés à l'heure ou au mois." } diff --git a/packages/manager/apps/pci-instances/public/translations/order-price/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/order-price/Messages_fr_FR.json new file mode 100644 index 000000000000..c911c227ee05 --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/order-price/Messages_fr_FR.json @@ -0,0 +1,8 @@ +{ + "order_catalog_price_tax_excl_label": "{{ price }} HT", + "order_catalog_price_tax_incl_label": "{{ price }} TTC", + "order_catalog_price_gst_excl_label": "{{ price }} ex. GST", + "order_catalog_price_gst_incl_label": "{{ price }} incl. GST", + "order_catalog_price_interval_month": "mois", + "order_catalog_price_interval_hour": "heure" +} diff --git a/packages/manager/apps/pci-instances/public/translations/stepper/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/stepper/Messages_fr_FR.json new file mode 100644 index 000000000000..0bde24eaa20d --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/stepper/Messages_fr_FR.json @@ -0,0 +1,4 @@ +{ + "next_button_label": "Suivant", + "edit_step_label": "Modifier cette étape" +} From 2808d7788ad28e6c097e6c0e3fe9c795be13b79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 22:00:41 +0200 Subject: [PATCH 43/76] test(pci-instances): add test suite for useAppStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- .../src/store/hooks/useAppStore.spec.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts diff --git a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts new file mode 100644 index 000000000000..5dc88c0cac69 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts @@ -0,0 +1,108 @@ +import { describe, test } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAppStore } from './useAppStore'; +import { TStep, TStepId } from '../slices/stepper.slice'; + +describe('Considering the useAppStore hook', () => { + describe("Considering the 'FormSlice'", () => { + // test data + type Data = { + modelName: string; + expectedModelName: string; + }; + const fakeModelName1 = 'b3-8'; + const fakeModelName2 = 'b2-7'; + + const expectedModelName1 = fakeModelName1; + const expectedModelName2 = fakeModelName2; + + describe.each` + modelName | expectedModelName + ${fakeModelName1} | ${expectedModelName1} + ${fakeModelName2} | ${expectedModelName2} + `( + 'Given a modelName <$modelName>', + ({ modelName, expectedModelName }: Data) => { + describe(`When invoking useAppStore hook,`, () => { + test(`Then, expect model name to be ${JSON.stringify( + expectedModelName, + )}`, () => { + const { result } = renderHook(() => useAppStore()); + expect(result.current).toHaveProperty('form'); + expect(result.current.modelName()).toBeNull(); + act(() => { + result.current.setModelName(modelName); + }); + expect(result.current.modelName()).toStrictEqual(expectedModelName); + }); + }); + }, + ); + }); + + describe("Considering the 'StepperSlice'", () => { + // test data + type Data = { + stepId: TStepId; + updatedStep?: TStep; + updatedStepId?: TStepId; + expectedStep: TStep; + expectedUpdatedStep?: TStep; + }; + const stepId1: TStepId = 'model'; + const updatedStepId1: TStepId = stepId1; + const updatedStepId2 = 'foo'; + + const expectedStep1: TStep = { + isChecked: false, + isLocked: false, + isOpen: true, + }; + + const updatedStep1: TStep = { + isChecked: true, + isLocked: true, + isOpen: false, + }; + + const expectedUpdatedStep1 = updatedStep1; + + describe.each` + stepId | expectedStep | updatedStepId | updatedStep | expectedUpdatedStep + ${undefined} | ${undefined} | ${undefined} | ${undefined} | ${undefined} + ${stepId1} | ${expectedStep1} | ${undefined} | ${undefined} | ${undefined} + ${stepId1} | ${expectedStep1} | ${updatedStepId1} | ${updatedStep1} | ${expectedUpdatedStep1} + ${stepId1} | ${expectedStep1} | ${updatedStepId2} | ${updatedStep1} | ${expectedStep1} + `( + 'Given a stepId <$stepId>', + ({ + stepId, + expectedStep, + updatedStep, + expectedUpdatedStep, + updatedStepId, + }: Data) => { + describe(`When invoking useAppStore hook`, () => { + test(`Then, expect step to be ${JSON.stringify( + expectedStep, + )}`, () => { + const { result } = renderHook(() => useAppStore()); + expect(result.current).toHaveProperty('steps'); + expect(result.current.steps).toBeInstanceOf(Map); + expect(result.current.stepState()(stepId)).toStrictEqual( + expectedStep, + ); + if (updatedStep && updatedStepId) { + act(() => { + result.current.updateStep(updatedStepId, updatedStep); + }); + expect(result.current.stepState()(stepId)).toStrictEqual( + expectedUpdatedStep, + ); + } + }); + }); + }, + ); + }); +}); From 091cc8a747da22f1c18339690898d98d72e3da14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 27 Sep 2024 22:09:27 +0200 Subject: [PATCH 44/76] feat(pci-instances): improve code quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1807 Signed-off-by: Frédéric Vilcot --- .../src/__mocks__/catalog/handlers.ts | 10 +- .../pci-instances/src/__mocks__/zustand.ts | 2 +- .../src/data/hooks/catalog/useCatalog.ts | 8 +- .../create/steps/ModelsStep.component.tsx | 281 ++++++++++-------- .../src/store/hooks/useAppStore.spec.ts | 4 +- .../src/store/hooks/useAppStore.ts | 3 + .../src/store/slices/stepper.slice.ts | 7 +- 7 files changed, 174 insertions(+), 141 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/__mocks__/catalog/handlers.ts b/packages/manager/apps/pci-instances/src/__mocks__/catalog/handlers.ts index a329da3233fd..0361adfb8e80 100644 --- a/packages/manager/apps/pci-instances/src/__mocks__/catalog/handlers.ts +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/handlers.ts @@ -1,5 +1,4 @@ -import { http, HttpResponse, JsonBodyType, RequestHandler, delay } from 'msw'; -import mockedCatalog from './fullCatalogGenerated.json'; +import { http, HttpResponse, JsonBodyType, RequestHandler } from 'msw'; export const catalogHandlers = ( mockedResponsePayload?: T, @@ -10,10 +9,3 @@ export const catalogHandlers = ( : HttpResponse.json(mockedResponsePayload); }), ]; - -export const browserHandlers: RequestHandler[] = [ - http.get('*/cloud/project/:projectId/catalog/instance', async () => { - await delay(2000); - return HttpResponse.json(mockedCatalog); - }), -]; diff --git a/packages/manager/apps/pci-instances/src/__mocks__/zustand.ts b/packages/manager/apps/pci-instances/src/__mocks__/zustand.ts index e0842711bb3c..7ab7ed480eb1 100644 --- a/packages/manager/apps/pci-instances/src/__mocks__/zustand.ts +++ b/packages/manager/apps/pci-instances/src/__mocks__/zustand.ts @@ -7,7 +7,7 @@ import { vi } from 'vitest'; * These lines allow to: * - run tests that use 'createStore' curried function * - clear stores after each test - * Warning: to work with vitest, this file must be under __mocks__ folder in the root project (.) + * Warning: to work with vitest, this file must be under __mocks__ folder in the vitest config's root project (./src) * */ const { create: actualCreate, diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts index 098b1b29bb9b..7b03caff66e2 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts @@ -116,10 +116,10 @@ const sortModels = (models: DeepReadonly) => models .slice() .sort((a, b) => { - const aGroup = Number((a.name.match(/[0-9]+/) || [])[0]); - const bGroup = Number((b.name.match(/[0-9]+/) || [])[0]); - const aRank = Number((a.name.match(/-([^-]+)$/) || [])[1]); - const bRank = Number((b.name.match(/-([^-]+)$/) || [])[1]); + const aGroup = Number((/\d+/.exec(a.name) || [])[0]); + const bGroup = Number((/\d+/.exec(b.name) || [])[0]); + const aRank = Number((/-([^-]+)$/.exec(a.name) || [])[1]); + const bRank = Number((/-([^-]+)$/.exec(b.name) || [])[1]); return aGroup === bGroup ? aRank - bRank : bGroup - aGroup; }) .sort( diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx index 92114e8cf416..e2028aa27f05 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx @@ -1,5 +1,6 @@ import { StepComponent, TabsComponent } from '@ovh-ux/manager-react-components'; import clsx from 'clsx'; +import { TFunction } from 'i18next'; import { FC, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; @@ -19,6 +20,7 @@ import { modelSelector, useCatalog } from '@/data/hooks/catalog/useCatalog'; import { Spinner } from '@/components/spinner/Spinner.component'; import { TModelCategory, + TModelEntity, TModelPricing, TPriceInterval, TStorage, @@ -29,30 +31,37 @@ import { useAppStore } from '@/store/hooks/useAppStore'; import { TStep, TStepId } from '@/store/slices/stepper.slice'; const modelStepId: TStepId = 'model'; -const validatedStepState: TStep = { - isOpen: true, +const validatedModelStepState: Partial = { isChecked: true, isLocked: true, }; -const editedStepState: TStep = { - isOpen: true, +const editedModelStepState: Partial = { isChecked: false, isLocked: false, }; -export const ModelsStep: FC = () => { - const { projectId } = useParams() as { projectId: string }; - const { t } = useTranslation(['create', 'stepper']); - const { data, isLoading } = useCatalog(projectId, modelSelector); - const { stepState, modelName, setModelName, updateStep } = useAppStore( - useShallow((state) => ({ - stepState: state.stepState(), - modelName: state.modelName(), - setModelName: state.setModelName, - updateStep: state.updateStep, - })), - ); +type TTabProps = { + t: TFunction; + category: DeepReadonly; +}; +type TTabContentProps = TTabProps & { + modelName: string | null; + setModelName: (newName: string) => void; + data?: TModelEntity; +}; + +type TTabTitleProps = TTabProps & { + isSelected?: boolean; +}; + +const TabContent: FC = ({ + t, + category, + data, + modelName, + setModelName, +}) => { const getModelPrice = useCallback( ( pricings: DeepReadonly, @@ -66,30 +75,158 @@ export const ModelsStep: FC = () => { (name: string) => () => setModelName(name), [setModelName], ); + return ( +
+ + {t(`model_category_${category.name}_description`)} + +
+ {data?.models.data + .filter((model) => model.category === category.name) + .map( + ({ + name, + specifications, + compatibleLocalzone, + compatibleRegion, + isNew, + pricings, + }) => { + const monthlyPrice = getModelPrice(pricings, 'month'); + const hourlyPrice = getModelPrice(pricings, 'hour') ?? 0; + return ( +
+ +
+ ); + }, + )} +
+
+ ); +}; + +const TabTitle: FC = ({ t, category, isSelected }) => ( + +
+ + {t(`model_category_${category.name}_title`)} + + {category.isNew && ( + + + {t('new')} + + + )} +
+
+); + +export const ModelsStep: FC = () => { + const { projectId } = useParams() as { projectId: string }; + const { t } = useTranslation(['create', 'stepper']); + const { data, isLoading } = useCatalog(projectId, modelSelector); + const { stepStateById, modelName, setModelName, updateStep } = useAppStore( + useShallow((state) => ({ + stepStateById: state.stepStateById(), + modelName: state.modelName(), + setModelName: state.setModelName, + updateStep: state.updateStep, + })), + ); const handleNextStep = useCallback( (id: string) => { - updateStep(id as TStepId, validatedStepState); + updateStep(id as TStepId, validatedModelStepState); }, [updateStep], ); const handleEditStep = useCallback( (id: string) => { - updateStep(id as TStepId, editedStepState); + updateStep(id as TStepId, editedModelStepState); }, [updateStep], ); - const currentStepState = useMemo(() => stepState(modelStepId), [stepState]); + const modelStepState = useMemo(() => stepStateById(modelStepId), [ + stepStateById, + ]); + + const tabTitle = useCallback( + (category: DeepReadonly, isSelected?: boolean) => ( + + ), + [t], + ); + + const tabElement = useCallback( + (category: DeepReadonly) => ( + + ), + [data, modelName, setModelName, t], + ); return (
{ {data && ( > items={data.models.categories as TModelCategory[]} - className="[&:last-child>li]:py-0" itemKey={({ name }) => name} - titleElement={(category, isSelected) => ( - -
- - {t(`model_category_${category.name}_title`)} - - {category.isNew && ( - - - {t('new')} - - - )} -
-
- )} - contentElement={(category) => ( -
- - {t(`model_category_${category.name}_description`)} - -
- {data.models.data - .filter((model) => model.category === category.name) - .map( - ({ - name, - specifications, - compatibleLocalzone, - compatibleRegion, - isNew, - pricings, - }) => { - const monthlyPrice = getModelPrice(pricings, 'month'); - const hourlyPrice = - getModelPrice(pricings, 'hour') ?? 0; - return ( -
- -
- ); - }, - )} -
-
- )} + titleElement={tabTitle} + contentElement={tabElement} /> )} diff --git a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts index 5dc88c0cac69..5e507ed291ca 100644 --- a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts +++ b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts @@ -89,14 +89,14 @@ describe('Considering the useAppStore hook', () => { const { result } = renderHook(() => useAppStore()); expect(result.current).toHaveProperty('steps'); expect(result.current.steps).toBeInstanceOf(Map); - expect(result.current.stepState()(stepId)).toStrictEqual( + expect(result.current.stepStateById()(stepId)).toStrictEqual( expectedStep, ); if (updatedStep && updatedStepId) { act(() => { result.current.updateStep(updatedStepId, updatedStep); }); - expect(result.current.stepState()(stepId)).toStrictEqual( + expect(result.current.stepStateById()(stepId)).toStrictEqual( expectedUpdatedStep, ); } diff --git a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.ts b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.ts index 599f408389ce..33a55edb8ade 100644 --- a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.ts +++ b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.ts @@ -1,9 +1,12 @@ import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; import { devtools } from 'zustand/middleware'; +import { enableMapSet } from 'immer'; import { createStepperSlice, TStepperSlice } from '../slices/stepper.slice'; import { createFormSlice, TFormSlice } from '../slices/form.slice'; +enableMapSet(); + export const useAppStore = create()( devtools( immer((...a) => ({ diff --git a/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts b/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts index 348b54c1162f..e472adcd3044 100644 --- a/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts +++ b/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts @@ -1,7 +1,4 @@ import { StateCreator } from 'zustand'; -import { enableMapSet } from 'immer'; - -enableMapSet(); export type TStepId = 'model'; @@ -22,7 +19,7 @@ export type TState = { * If used with a parameter, currification enables reactivity for getters. */ export type TQuery = { - stepState: () => (stepId: TStepId) => TStep | undefined; + stepStateById: () => (stepId: TStepId) => TStep | undefined; }; // Handlers @@ -47,7 +44,7 @@ export const createStepperSlice: StateCreator< TStepperSlice > = (set, get) => ({ steps: initialSteps, - stepState: () => (stepId) => get().steps.get(stepId), + stepStateById: () => (stepId) => get().steps.get(stepId), updateStep: (stepId, step) => set((state) => { const newSteps = new Map(state.steps); From 2618140941451af3f039cafb38529ac35fcf1157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Mon, 30 Sep 2024 11:12:01 +0200 Subject: [PATCH 45/76] fix(pci-instances): bump manager-pci-common package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- packages/manager/apps/pci-instances/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/apps/pci-instances/package.json b/packages/manager/apps/pci-instances/package.json index 7ad7124dd14a..0fcf21efdbbc 100644 --- a/packages/manager/apps/pci-instances/package.json +++ b/packages/manager/apps/pci-instances/package.json @@ -18,7 +18,7 @@ "dependencies": { "@ovh-ux/manager-config": "^7.3.2", "@ovh-ux/manager-core-api": "^0.8.0", - "@ovh-ux/manager-pci-common": "^0.3.0", + "@ovh-ux/manager-pci-common": "^0.4.1", "@ovh-ux/manager-react-components": "^1.26.0", "@ovh-ux/manager-react-core-application": "^0.10.0", "@ovh-ux/manager-react-shell-client": "^0.7.0", From abdcb11c24319b49858aca0d4dad7381d5df4b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Wed, 7 Aug 2024 19:15:20 +0200 Subject: [PATCH 46/76] feat(pci-instance): add package first configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- .../manager/apps/pci-instance/.eslintrc.cjs | 24 +++++++ packages/manager/apps/pci-instance/.gitignore | 25 +++++++ packages/manager/apps/pci-instance/README.md | 1 + packages/manager/apps/pci-instance/index.html | 12 ++++ .../manager/apps/pci-instance/package.json | 70 +++++++++++++++++++ .../apps/pci-instance/postcss.config.cjs | 6 ++ .../apps/pci-instance/tailwind.config.js | 13 ++++ 7 files changed, 151 insertions(+) create mode 100644 packages/manager/apps/pci-instance/.eslintrc.cjs create mode 100644 packages/manager/apps/pci-instance/.gitignore create mode 100644 packages/manager/apps/pci-instance/README.md create mode 100644 packages/manager/apps/pci-instance/index.html create mode 100644 packages/manager/apps/pci-instance/package.json create mode 100644 packages/manager/apps/pci-instance/postcss.config.cjs create mode 100644 packages/manager/apps/pci-instance/tailwind.config.js diff --git a/packages/manager/apps/pci-instance/.eslintrc.cjs b/packages/manager/apps/pci-instance/.eslintrc.cjs new file mode 100644 index 000000000000..783e63c17e3f --- /dev/null +++ b/packages/manager/apps/pci-instance/.eslintrc.cjs @@ -0,0 +1,24 @@ +module.exports = { + extends: [ + '../../../../.eslintrc.js', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + 'plugin:prettier/recommended', + ], + settings: { + react: { + version: 'detect', + }, + }, + globals: { + __VERSION__: true, + }, + + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { ignoreRestSiblings: true }, + ], + }, +}; diff --git a/packages/manager/apps/pci-instance/.gitignore b/packages/manager/apps/pci-instance/.gitignore new file mode 100644 index 000000000000..785a3087cb53 --- /dev/null +++ b/packages/manager/apps/pci-instance/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/manager/apps/pci-instance/README.md b/packages/manager/apps/pci-instance/README.md new file mode 100644 index 000000000000..03b8e24e95bb --- /dev/null +++ b/packages/manager/apps/pci-instance/README.md @@ -0,0 +1 @@ +# PCI Instance diff --git a/packages/manager/apps/pci-instance/index.html b/packages/manager/apps/pci-instance/index.html new file mode 100644 index 000000000000..fedc5e25f607 --- /dev/null +++ b/packages/manager/apps/pci-instance/index.html @@ -0,0 +1,12 @@ + + + + + + OVHcloud + + +
+ + + diff --git a/packages/manager/apps/pci-instance/package.json b/packages/manager/apps/pci-instance/package.json new file mode 100644 index 000000000000..17afb0cce89f --- /dev/null +++ b/packages/manager/apps/pci-instance/package.json @@ -0,0 +1,70 @@ +{ + "name": "@ovh-ux/manager-pci-instance-app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --project tsconfig.build.json && vite build", + "coverage": "vitest run --coverage", + "dev": "vite", + "lint": "eslint ./src", + "start": "lerna exec --stream --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run build --if-present", + "start:dev": "lerna exec --stream --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run dev --if-present", + "start:watch": "lerna exec --stream --parallel --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run dev:watch --if-present", + "test": "vitest run", + "test:watch": "vitest --watch", + "type:check": "tsc --noEmit" + }, + "dependencies": { + "@ovh-ux/manager-config": "^7.3.2", + "@ovh-ux/manager-core-api": "^0.8.0", + "@ovh-ux/manager-react-core-application": "^0.10.0", + "@ovh-ux/manager-react-shell-client": "^0.7.0", + "@ovh-ux/manager-tailwind-config": "^0.2.0", + "@ovhcloud/manager-components": "^1.26.0", + "@ovhcloud/ods-common-core": "17.2.2", + "@ovhcloud/ods-common-stencil": "17.2.2", + "@ovhcloud/ods-common-theming": "17.2.2", + "@ovhcloud/ods-components": "17.2.2", + "@ovhcloud/ods-theme-blue-jeans": "17.2.2", + "@tanstack/react-query": "^5.51.21", + "axios": "^1.6.8", + "element-internals-polyfill": "^1.3.10", + "i18next": "^23.8.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^14.0.5", + "react-router-dom": "^6.3.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "@ovh-ux/manager-vite-config": "^0.8.0", + "@tanstack/react-query-devtools": "^5.51.21", + "@testing-library/dom": "^9.3.4", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.12", + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@types/react-query": "^1.2.9", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^1.2.2", + "autoprefixer": "^10.4.17", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.35.0", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.4", + "vite": "^5.2.13", + "vitest": "^1.2.0" + }, + "regions": [ + "CA", + "EU", + "US" + ], + "universes": [ + "@ovh-ux/manager-public-cloud" + ] +} diff --git a/packages/manager/apps/pci-instance/postcss.config.cjs b/packages/manager/apps/pci-instance/postcss.config.cjs new file mode 100644 index 000000000000..12a703d900da --- /dev/null +++ b/packages/manager/apps/pci-instance/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/manager/apps/pci-instance/tailwind.config.js b/packages/manager/apps/pci-instance/tailwind.config.js new file mode 100644 index 000000000000..ac500dd0cc38 --- /dev/null +++ b/packages/manager/apps/pci-instance/tailwind.config.js @@ -0,0 +1,13 @@ +import config from '@ovh-ux/manager-tailwind-config'; + +/** @type {import('tailwindcss').Config} */ +module.exports = { + ...config, + content: [ + './src/**/*.{js,jsx,ts,tsx}', + '../../../manager-components/src/**/*.{js,jsx,ts,tsx}', + ], + corePlugins: { + preflight: false, + }, +}; From a32053b75cff48200237cbebd44f840b9f9af136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 8 Oct 2024 15:50:41 +0200 Subject: [PATCH 47/76] refactor(pci-instances): restructure types folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:1864 Signed-off-by: Frédéric Vilcot --- .../manager/apps/pci-instance/.eslintrc.cjs | 24 ------- packages/manager/apps/pci-instance/.gitignore | 25 ------- packages/manager/apps/pci-instance/README.md | 1 - packages/manager/apps/pci-instance/index.html | 12 ---- .../manager/apps/pci-instance/package.json | 70 ------------------- .../apps/pci-instance/postcss.config.cjs | 6 -- .../apps/pci-instance/tailwind.config.js | 13 ---- .../manager/apps/pci-instances/package.json | 2 +- .../{instances => instance}/handlers.ts | 0 .../__mocks__/{instances => instance}/node.ts | 0 .../statusChip/StatusChip.component.tsx | 2 +- .../data/api/{instances.ts => instance.ts} | 2 +- .../useInstances.spec.tsx | 4 +- .../{instances => instance}/useInstances.ts | 4 +- .../src/pages/instances/Instances.page.tsx | 2 +- .../instances/onboarding/Onboarding.page.tsx | 2 +- .../{instances => instance}/api.types.ts | 0 .../pci-instances/src/types/utils.type.ts | 9 +-- 18 files changed, 11 insertions(+), 167 deletions(-) delete mode 100644 packages/manager/apps/pci-instance/.eslintrc.cjs delete mode 100644 packages/manager/apps/pci-instance/.gitignore delete mode 100644 packages/manager/apps/pci-instance/README.md delete mode 100644 packages/manager/apps/pci-instance/index.html delete mode 100644 packages/manager/apps/pci-instance/package.json delete mode 100644 packages/manager/apps/pci-instance/postcss.config.cjs delete mode 100644 packages/manager/apps/pci-instance/tailwind.config.js rename packages/manager/apps/pci-instances/src/__mocks__/{instances => instance}/handlers.ts (100%) rename packages/manager/apps/pci-instances/src/__mocks__/{instances => instance}/node.ts (100%) rename packages/manager/apps/pci-instances/src/data/api/{instances.ts => instance.ts} (93%) rename packages/manager/apps/pci-instances/src/data/hooks/{instances => instance}/useInstances.spec.tsx (99%) rename packages/manager/apps/pci-instances/src/data/hooks/{instances => instance}/useInstances.ts (98%) rename packages/manager/apps/pci-instances/src/types/{instances => instance}/api.types.ts (100%) diff --git a/packages/manager/apps/pci-instance/.eslintrc.cjs b/packages/manager/apps/pci-instance/.eslintrc.cjs deleted file mode 100644 index 783e63c17e3f..000000000000 --- a/packages/manager/apps/pci-instance/.eslintrc.cjs +++ /dev/null @@ -1,24 +0,0 @@ -module.exports = { - extends: [ - '../../../../.eslintrc.js', - 'plugin:react/recommended', - 'plugin:react/jsx-runtime', - 'plugin:react-hooks/recommended', - 'plugin:prettier/recommended', - ], - settings: { - react: { - version: 'detect', - }, - }, - globals: { - __VERSION__: true, - }, - - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { ignoreRestSiblings: true }, - ], - }, -}; diff --git a/packages/manager/apps/pci-instance/.gitignore b/packages/manager/apps/pci-instance/.gitignore deleted file mode 100644 index 785a3087cb53..000000000000 --- a/packages/manager/apps/pci-instance/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -coverage -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/packages/manager/apps/pci-instance/README.md b/packages/manager/apps/pci-instance/README.md deleted file mode 100644 index 03b8e24e95bb..000000000000 --- a/packages/manager/apps/pci-instance/README.md +++ /dev/null @@ -1 +0,0 @@ -# PCI Instance diff --git a/packages/manager/apps/pci-instance/index.html b/packages/manager/apps/pci-instance/index.html deleted file mode 100644 index fedc5e25f607..000000000000 --- a/packages/manager/apps/pci-instance/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - OVHcloud - - -
- - - diff --git a/packages/manager/apps/pci-instance/package.json b/packages/manager/apps/pci-instance/package.json deleted file mode 100644 index 17afb0cce89f..000000000000 --- a/packages/manager/apps/pci-instance/package.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "@ovh-ux/manager-pci-instance-app", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsc --project tsconfig.build.json && vite build", - "coverage": "vitest run --coverage", - "dev": "vite", - "lint": "eslint ./src", - "start": "lerna exec --stream --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run build --if-present", - "start:dev": "lerna exec --stream --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run dev --if-present", - "start:watch": "lerna exec --stream --parallel --scope='@ovh-ux/manager-pci-instance-app' --include-dependencies -- npm run dev:watch --if-present", - "test": "vitest run", - "test:watch": "vitest --watch", - "type:check": "tsc --noEmit" - }, - "dependencies": { - "@ovh-ux/manager-config": "^7.3.2", - "@ovh-ux/manager-core-api": "^0.8.0", - "@ovh-ux/manager-react-core-application": "^0.10.0", - "@ovh-ux/manager-react-shell-client": "^0.7.0", - "@ovh-ux/manager-tailwind-config": "^0.2.0", - "@ovhcloud/manager-components": "^1.26.0", - "@ovhcloud/ods-common-core": "17.2.2", - "@ovhcloud/ods-common-stencil": "17.2.2", - "@ovhcloud/ods-common-theming": "17.2.2", - "@ovhcloud/ods-components": "17.2.2", - "@ovhcloud/ods-theme-blue-jeans": "17.2.2", - "@tanstack/react-query": "^5.51.21", - "axios": "^1.6.8", - "element-internals-polyfill": "^1.3.10", - "i18next": "^23.8.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-i18next": "^14.0.5", - "react-router-dom": "^6.3.0", - "zustand": "^4.5.0" - }, - "devDependencies": { - "@jest/globals": "^29.7.0", - "@ovh-ux/manager-vite-config": "^0.8.0", - "@tanstack/react-query-devtools": "^5.51.21", - "@testing-library/dom": "^9.3.4", - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^14.2.1", - "@testing-library/user-event": "^14.5.2", - "@types/jest": "^29.5.12", - "@types/react": "^18.2.55", - "@types/react-dom": "^18.2.19", - "@types/react-query": "^1.2.9", - "@vitejs/plugin-react": "^4.2.1", - "@vitest/coverage-v8": "^1.2.2", - "autoprefixer": "^10.4.17", - "eslint": "^8.57.0", - "eslint-plugin-react": "^7.35.0", - "postcss": "^8.4.35", - "tailwindcss": "^3.4.4", - "vite": "^5.2.13", - "vitest": "^1.2.0" - }, - "regions": [ - "CA", - "EU", - "US" - ], - "universes": [ - "@ovh-ux/manager-public-cloud" - ] -} diff --git a/packages/manager/apps/pci-instance/postcss.config.cjs b/packages/manager/apps/pci-instance/postcss.config.cjs deleted file mode 100644 index 12a703d900da..000000000000 --- a/packages/manager/apps/pci-instance/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/manager/apps/pci-instance/tailwind.config.js b/packages/manager/apps/pci-instance/tailwind.config.js deleted file mode 100644 index ac500dd0cc38..000000000000 --- a/packages/manager/apps/pci-instance/tailwind.config.js +++ /dev/null @@ -1,13 +0,0 @@ -import config from '@ovh-ux/manager-tailwind-config'; - -/** @type {import('tailwindcss').Config} */ -module.exports = { - ...config, - content: [ - './src/**/*.{js,jsx,ts,tsx}', - '../../../manager-components/src/**/*.{js,jsx,ts,tsx}', - ], - corePlugins: { - preflight: false, - }, -}; diff --git a/packages/manager/apps/pci-instances/package.json b/packages/manager/apps/pci-instances/package.json index 0fcf21efdbbc..74f83da0eca7 100644 --- a/packages/manager/apps/pci-instances/package.json +++ b/packages/manager/apps/pci-instances/package.json @@ -18,7 +18,7 @@ "dependencies": { "@ovh-ux/manager-config": "^7.3.2", "@ovh-ux/manager-core-api": "^0.8.0", - "@ovh-ux/manager-pci-common": "^0.4.1", + "@ovh-ux/manager-pci-common": "^0.6.1", "@ovh-ux/manager-react-components": "^1.26.0", "@ovh-ux/manager-react-core-application": "^0.10.0", "@ovh-ux/manager-react-shell-client": "^0.7.0", diff --git a/packages/manager/apps/pci-instances/src/__mocks__/instances/handlers.ts b/packages/manager/apps/pci-instances/src/__mocks__/instance/handlers.ts similarity index 100% rename from packages/manager/apps/pci-instances/src/__mocks__/instances/handlers.ts rename to packages/manager/apps/pci-instances/src/__mocks__/instance/handlers.ts diff --git a/packages/manager/apps/pci-instances/src/__mocks__/instances/node.ts b/packages/manager/apps/pci-instances/src/__mocks__/instance/node.ts similarity index 100% rename from packages/manager/apps/pci-instances/src/__mocks__/instances/node.ts rename to packages/manager/apps/pci-instances/src/__mocks__/instance/node.ts diff --git a/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx index dfb00852425c..22b89bf836ba 100644 --- a/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx @@ -1,7 +1,7 @@ import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; import { OsdsChip } from '@ovhcloud/ods-components/react'; import { useTranslation } from 'react-i18next'; -import { TInstanceStatus } from '@/data/hooks/instances/useInstances'; +import { TInstanceStatus } from '@/data/hooks/instance/useInstances'; const colorBySeverityStatus = { success: ODS_THEME_COLOR_INTENT.success, diff --git a/packages/manager/apps/pci-instances/src/data/api/instances.ts b/packages/manager/apps/pci-instances/src/data/api/instance.ts similarity index 93% rename from packages/manager/apps/pci-instances/src/data/api/instances.ts rename to packages/manager/apps/pci-instances/src/data/api/instance.ts index 4b7a634670c0..3c0efad4f644 100644 --- a/packages/manager/apps/pci-instances/src/data/api/instances.ts +++ b/packages/manager/apps/pci-instances/src/data/api/instance.ts @@ -2,7 +2,7 @@ import { v6 } from '@ovh-ux/manager-core-api'; import { TInstanceDto, TRetrieveInstancesQueryParams, -} from '@/types/instances/api.types'; +} from '@/types/instance/api.types'; export const getInstances = ( projectId: string, diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.spec.tsx similarity index 99% rename from packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx rename to packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.spec.tsx index e0091392a785..60db928f560c 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.spec.tsx @@ -11,8 +11,8 @@ import { useInstances, TUseInstancesQueryParams, } from './useInstances'; -import { setupInstanceServer } from '@/__mocks__/instances/node'; -import { TInstanceDto, TInstanceStatusDto } from '@/types/instances/api.types'; +import { setupInstanceServer } from '@/__mocks__/instance/node'; +import { TInstanceDto, TInstanceStatusDto } from '@/types/instance/api.types'; // builders const instanceDtoBuilder = ( diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts similarity index 98% rename from packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts rename to packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts index e729c1134d27..63658a63f27f 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instances/useInstances.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts @@ -8,10 +8,10 @@ import { } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo } from 'react'; import { FilterWithLabel } from '@ovh-ux/manager-react-components/src/components/filters/interface'; -import { getInstances } from '@/data/api/instances'; +import { getInstances } from '@/data/api/instance'; import { instancesQueryKey } from '@/utils'; import { DeepReadonly } from '@/types/utils.type'; -import { TInstanceDto, TInstanceStatusDto } from '@/types/instances/api.types'; +import { TInstanceDto, TInstanceStatusDto } from '@/types/instance/api.types'; export type TUseInstancesQueryParams = DeepReadonly<{ limit: number; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index 849db955d0c9..f74688ddadb1 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -45,7 +45,7 @@ import { import { Trans, useTranslation } from 'react-i18next'; import { FilterComparator } from '@ovh-ux/manager-core-api'; import { Spinner } from '@/components/spinner/Spinner.component'; -import { TInstance, useInstances } from '@/data/hooks/instances/useInstances'; +import { TInstance, useInstances } from '@/data/hooks/instance/useInstances'; import StatusChip from '@/components/statusChip/StatusChip.component'; import { Breadcrumb } from '@/components/breadcrumb/Breadcrumb.component'; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx index b18a1ed039c6..4d1dcf948d9c 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx @@ -24,7 +24,7 @@ import { useHidePreloader } from '@/hooks/hidePreloader/useHidePreloader'; import { Breadcrumb } from '@/components/breadcrumb/Breadcrumb.component'; import InstanceImageSrc from '../../../../public/assets/instance.png'; import { GUIDES } from './onboarding.constants'; -import { useInstances } from '@/data/hooks/instances/useInstances'; +import { useInstances } from '@/data/hooks/instance/useInstances'; import { Spinner } from '@/components/spinner/Spinner.component'; const Onboarding: FC = () => { diff --git a/packages/manager/apps/pci-instances/src/types/instances/api.types.ts b/packages/manager/apps/pci-instances/src/types/instance/api.types.ts similarity index 100% rename from packages/manager/apps/pci-instances/src/types/instances/api.types.ts rename to packages/manager/apps/pci-instances/src/types/instance/api.types.ts diff --git a/packages/manager/apps/pci-instances/src/types/utils.type.ts b/packages/manager/apps/pci-instances/src/types/utils.type.ts index e8293e3d4774..8055e2aca71f 100644 --- a/packages/manager/apps/pci-instances/src/types/utils.type.ts +++ b/packages/manager/apps/pci-instances/src/types/utils.type.ts @@ -1,10 +1,5 @@ -export type Primitive = - | undefined - | null - | boolean - | string - | number - | (() => unknown); +// eslint-disable-next-line @typescript-eslint/ban-types +export type Primitive = undefined | null | boolean | string | number | Function; export type DeepReadonlyMap = ReadonlyMap< DeepReadonly, DeepReadonly From e3e11f9d00c4a869dc1b87c4953b712a777d69fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 8 Oct 2024 16:06:03 +0200 Subject: [PATCH 48/76] feat(pci-instances): add region selector for catalog hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1864 Signed-off-by: Frédéric Vilcot --- .../catalog/selectors/models.selector.ts | 131 +++++++++++++ .../catalog/selectors/regions.selector.ts | 145 ++++++++++++++ .../src/data/hooks/catalog/useCatalog.ts | 185 ++++++------------ .../src/types/catalog/api.types.ts | 10 +- .../src/types/catalog/entity.types.ts | 37 ++++ 5 files changed, 377 insertions(+), 131 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/catalog/selectors/models.selector.ts create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/catalog/selectors/regions.selector.ts diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/selectors/models.selector.ts b/packages/manager/apps/pci-instances/src/data/hooks/catalog/selectors/models.selector.ts new file mode 100644 index 000000000000..a7909066d4f4 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/selectors/models.selector.ts @@ -0,0 +1,131 @@ +import { + TCpuDto, + TMemoryDto, + TGpuDto, + TDiskDto, + TSpecificationsDto, + TPricingDto, + TModelDto, + TCatalogDto, +} from '@/types/catalog/api.types'; +import { + TCpu, + TMemory, + TGpu, + TStorage, + TModelSpecification, + TModelPricing, + TModel, + TModelEntity, +} from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; + +export type TModelSelector = (rawData: TCatalogDto) => TModelEntity; + +const getSizeUnit = (size: number) => (size < 1000 ? 'Go' : 'To'); + +const mapModelCpu = (cpu: TCpuDto): TCpu => { + const { type, ...rest } = cpu; + return rest; +}; + +const mapModelMemory = (memory: TMemoryDto): TMemory => ({ + size: memory.size, + unit: getSizeUnit(memory.size), +}); + +const mapModelGpu = ({ model, number }: TGpuDto): TGpu => ({ + model, + number, +}); + +const mapModelStorages = ( + storages: DeepReadonly, +): DeepReadonly => + storages.map((storage) => ({ + ...storage, + sizeUnit: getSizeUnit(storage.capacity), + })); + +const mapModelSpecifications = ( + specifications: DeepReadonly, +): DeepReadonly<{ specifications: TModelSpecification }> => ({ + specifications: { + memory: mapModelMemory(specifications.memory), + cpu: mapModelCpu(specifications.cpu), + bandwidth: specifications.bandwidth.level, + storage: mapModelStorages(specifications.storage.disks), + gpu: mapModelGpu(specifications.gpu), + }, +}); + +const mapModelPricings = ( + pricings: DeepReadonly, +): DeepReadonly<{ pricings: TModelPricing[] }> => ({ + pricings: pricings + .reduce( + (acc: DeepReadonly, cur: DeepReadonly) => { + if (!acc.length) return [cur]; + + const foundPricingByInterval = acc.find( + (elt) => elt.interval === cur.interval, + ); + + if (!foundPricingByInterval) return [...acc, cur]; + if (foundPricingByInterval.price < cur.price) return acc; + return [ + ...acc.filter((elt) => elt.price !== foundPricingByInterval.price), + cur, + ]; + }, + [] as TPricingDto[], + ) + .map(({ regions, osType, ...rest }) => rest), +}); + +const mapModelsData = ( + models: DeepReadonly, +): DeepReadonly => + models.map( + ({ + category, + name, + isNew, + compatibleLocalzone, + compatibleRegion, + banners, + pricings, + specifications, + }) => ({ + category, + name, + isNew, + compatibleLocalzone, + compatibleRegion, + banners, + ...mapModelPricings(pricings), + ...mapModelSpecifications(specifications), + }), + ); + +const sortModels = (models: DeepReadonly) => + models + .slice() + .sort((a, b) => { + const aGroup = Number((/\d+/.exec(a.name) || [])[0]); + const bGroup = Number((/\d+/.exec(b.name) || [])[0]); + const aRank = Number((/-([^-]+)$/.exec(a.name) || [])[1]); + const bRank = Number((/-([^-]+)$/.exec(b.name) || [])[1]); + return aGroup === bGroup ? aRank - bRank : bGroup - aGroup; + }) + .sort( + (a, b) => Number(b.compatibleLocalzone) - Number(a.compatibleLocalzone), + ) + .sort((a, b) => Number(b.isNew) - Number(a.isNew)); + +export const modelSelector: TModelSelector = (rawData) => ({ + models: { + categories: rawData.categories, + data: sortModels(mapModelsData(rawData.models)), + }, +}); diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/selectors/regions.selector.ts b/packages/manager/apps/pci-instances/src/data/hooks/catalog/selectors/regions.selector.ts new file mode 100644 index 000000000000..51fa0d9b8f59 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/selectors/regions.selector.ts @@ -0,0 +1,145 @@ +import { TCatalogDto, TModelDto, TRegionDto } from '@/types/catalog/api.types'; +import { + TRegionEntity, + TRegionAvailability, + TRegionsData, + TRegionCategory, + TRegion, +} from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; + +export type TRegionSelector = ( + rawData: TCatalogDto, + selectedModel: string | null, +) => TRegionEntity | undefined; + +const getAvailableRegionsByModel = ( + rawModels: DeepReadonly, + selectedModel: string | null, +): string[] => { + const foundPricingsByModel = rawModels.find( + (model) => model.name === selectedModel, + )?.pricings; + + if (!foundPricingsByModel || !selectedModel) return []; + return [ + ...foundPricingsByModel.reduce((acc, cur) => { + cur.regions.forEach(acc.add.bind(acc)); + return acc; + }, new Set()), + ]; +}; + +const getRegionsByAvailability = ( + rawData: TCatalogDto, + selectedModel: string, + availability: TRegionAvailability, +): TRegion[] => { + const modelAvailableRegions = getAvailableRegionsByModel( + rawData.models, + selectedModel, + ); + + const filterPredicate = (rawRegion: TRegionDto): boolean => + modelAvailableRegions.some((elt) => elt === rawRegion.name); + + return availability === 'available' + ? rawData.regions.filter(filterPredicate) + : rawData.regions.filter((elt) => !filterPredicate(elt)); +}; + +const getAvailableMicroRegions = ( + rawData: TCatalogDto, + selectedModel: string, +): TRegion[] => { + const allAvailableRegions = getRegionsByAvailability( + rawData, + selectedModel, + 'available', + ); + + const lookup = allAvailableRegions.reduce((acc, cur) => { + acc.set(cur.datacenter, (acc.get(cur.datacenter) ?? 0) + 1); + return acc; + }, new Map()); + + return allAvailableRegions.filter( + (region) => lookup.get(region.datacenter) > 1, + ); +}; + +const getAvailableMacroRegions = ( + rawData: TCatalogDto, + selectedModel: string, +): TRegion[] => { + const allAvailableRegions = getRegionsByAvailability( + rawData, + selectedModel, + 'available', + ); + + return allAvailableRegions.reduce((acc, cur) => { + if (!acc.length) return [cur]; + const foundDatacenter = acc.find( + (elt) => elt.datacenter === cur.datacenter, + ); + if (!foundDatacenter) return [...acc, cur]; + return acc; + }, [] as TRegion[]); +}; + +const mapRegionsData = ( + rawData: TCatalogDto, + selectedModel: string, +): TRegionsData => { + const allAvailableRegions = getRegionsByAvailability( + rawData, + selectedModel, + 'available', + ); + const availableMacroRegions = getAvailableMacroRegions( + rawData, + selectedModel, + ); + const availableMicroRegions = getAvailableMicroRegions( + rawData, + selectedModel, + ); + const unavailableRegions = getRegionsByAvailability( + rawData, + selectedModel, + 'unavailable', + ); + + return { + allAvailableRegions, + availableMacroRegions, + availableMicroRegions, + unavailableRegions, + }; +}; + +const getRegionCategories = (rawData: TCatalogDto): TRegionCategory[] => { + const allRegionCategory: TRegionCategory = { + name: 'all', + isNew: false, + }; + + return [allRegionCategory, ...rawData.regionCategories]; +}; + +export const regionSelector: TRegionSelector = (rawData, selectedModel) => { + if ( + !selectedModel || + !rawData.regionCategories.length || + !rawData.regions.length + ) + return undefined; + + return { + regions: { + categories: getRegionCategories(rawData), + data: mapRegionsData(rawData, selectedModel), + }, + }; +}; diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts index 7b03caff66e2..8e71afc7b969 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.ts @@ -1,145 +1,71 @@ -import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { keepPreviousData, QueryClient, useQuery } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { instancesQueryKey } from '@/utils'; import { getCatalog } from '@/data/api/catalog'; -import { - TCatalogDto, - TCpuDto, - TDiskDto, - TGpuDto, - TMemoryDto, - TModelDto, - TPricingDto, - TSpecificationsDto, -} from '@/types/catalog/api.types'; -import { - TCpu, - TGpu, - TMemory, - TModel, - TModelEntity, - TModelPricing, - TModelSpecification, - TStorage, -} from '@/types/catalog/entity.types'; -import { DeepReadonly } from '@/types/utils.type'; +import { TCatalogDto } from '@/types/catalog/api.types'; +import { useAppStore } from '@/store/hooks/useAppStore'; +import { modelSelector } from './selectors/models.selector'; +import { regionSelector } from './selectors/regions.selector'; -export type TModelSelector = (rawData: TCatalogDto) => TModelEntity; - -const getSizeUnit = (size: number) => (size < 1000 ? 'Go' : 'To'); - -const mapModelCpu = (cpu: TCpuDto): TCpu => { - const { type, ...rest } = cpu; - return rest; +export type TUseCatalogOptionsArg = { + selector?: TUseCatalogSelector; + enabled?: boolean; }; +export type TUseCatalogSelector = 'modelSelector' | 'regionSelector' | void; -const mapModelMemory = (memory: TMemoryDto): TMemory => ({ - size: memory.size, - unit: getSizeUnit(memory.size), -}); - -const mapModelGpu = ({ model, number }: TGpuDto): TGpu => ({ - model, - number, -}); - -const mapModelStorages = ( - storages: DeepReadonly, -): DeepReadonly => - storages.map((storage) => ({ - ...storage, - sizeUnit: getSizeUnit(storage.capacity), - })); - -const mapModelSpecifications = ( - specifications: DeepReadonly, -): DeepReadonly<{ specifications: TModelSpecification }> => ({ - specifications: { - memory: mapModelMemory(specifications.memory), - cpu: mapModelCpu(specifications.cpu), - bandwidth: specifications.bandwidth.level, - storage: mapModelStorages(specifications.storage.disks), - gpu: mapModelGpu(specifications.gpu), - }, -}); - -const mapModelPricings = ( - pricings: DeepReadonly, -): DeepReadonly<{ pricings: TModelPricing[] }> => ({ - pricings: pricings - .reduce( - (acc: DeepReadonly, cur: DeepReadonly) => { - if (!acc.length) return [cur]; - - const foundPricingByInterval = acc.find( - (elt) => elt.interval === cur.interval, - ); +type TSelectorData = T extends 'modelSelector' + ? ReturnType + : T extends 'regionSelector' + ? ReturnType + : TCatalogDto; - if (!foundPricingByInterval) return [...acc, cur]; - if (foundPricingByInterval.price < cur.price) return acc; - return [ - ...acc.filter((elt) => elt.price !== foundPricingByInterval.price), - cur, - ]; - }, - [] as TPricingDto[], - ) - .map(({ regions, osType, ...rest }) => rest), -}); - -const mapModelsData = ( - models: DeepReadonly, -): DeepReadonly => - models.map( - ({ - category, - name, - isNew, - compatibleLocalzone, - compatibleRegion, - banners, - pricings, - specifications, - }) => ({ - category, - name, - isNew, - compatibleLocalzone, - compatibleRegion, - banners, - ...mapModelPricings(pricings), - ...mapModelSpecifications(specifications), - }), +export const updateCatalogQueryData = ( + queryClient: QueryClient, + projectId: string, + regionName: string, +) => { + queryClient.setQueryData( + instancesQueryKey(projectId, ['catalog']), + (prev: TCatalogDto | undefined): TCatalogDto | undefined => + prev + ? { + ...prev, + regions: prev.regions.map((prevRegion) => + prevRegion.name === regionName + ? { ...prevRegion, isActivated: true } + : prevRegion, + ), + } + : prev, ); +}; -const sortModels = (models: DeepReadonly) => - models - .slice() - .sort((a, b) => { - const aGroup = Number((/\d+/.exec(a.name) || [])[0]); - const bGroup = Number((/\d+/.exec(b.name) || [])[0]); - const aRank = Number((/-([^-]+)$/.exec(a.name) || [])[1]); - const bRank = Number((/-([^-]+)$/.exec(b.name) || [])[1]); - return aGroup === bGroup ? aRank - bRank : bGroup - aGroup; - }) - .sort( - (a, b) => Number(b.compatibleLocalzone) - Number(a.compatibleLocalzone), - ) - .sort((a, b) => Number(b.isNew) - Number(a.isNew)); - -export const modelSelector: TModelSelector = (rawData) => ({ - models: { - categories: rawData.categories, - data: sortModels(mapModelsData(rawData.models)), - }, -}); - -export const useCatalog = (projectId: string, select: TModelSelector) => { +export const useCatalog = ( + projectId: string, + { selector, enabled }: TUseCatalogOptionsArg = {}, +) => { + const selectedModel = useAppStore.getState().modelName(); const queryKey = useMemo(() => instancesQueryKey(projectId, ['catalog']), [ projectId, ]); + const fetchCatalog = useCallback(() => getCatalog(projectId), [projectId]); + const catalogSelector = useCallback( + (rawData: TCatalogDto): TSelectorData => { + switch (selector) { + case 'modelSelector': + return modelSelector(rawData) as TSelectorData; + case 'regionSelector': { + return regionSelector(rawData, selectedModel) as TSelectorData; + } + default: + return rawData as TSelectorData; + } + }, + [selector, selectedModel], + ); + return useQuery({ queryKey, retry: false, @@ -148,6 +74,7 @@ export const useCatalog = (projectId: string, select: TModelSelector) => { refetchOnWindowFocus: false, queryFn: fetchCatalog, placeholderData: keepPreviousData, - select: useCallback(select, [select]), + ...(selector !== undefined && { select: catalogSelector }), + ...(enabled !== undefined && { enabled }), }); }; diff --git a/packages/manager/apps/pci-instances/src/types/catalog/api.types.ts b/packages/manager/apps/pci-instances/src/types/catalog/api.types.ts index efae6ed34b46..54118450ff62 100644 --- a/packages/manager/apps/pci-instances/src/types/catalog/api.types.ts +++ b/packages/manager/apps/pci-instances/src/types/catalog/api.types.ts @@ -8,12 +8,17 @@ export type TCategoryDto = { export type TRegionDto = { name: string; - country: string; + country: string | null; datacenter: string; isLocalzone: boolean; isInMaintenance: boolean; isActivated: boolean; - category: TCategoryDto; + category: string; +}; + +export type TRegionCategoryDto = { + name: string; + isNew: boolean; }; export type TPriceIntervalDto = 'hour' | 'month'; @@ -95,4 +100,5 @@ export type TCatalogDto = DeepReadonly<{ models: TModelDto[]; categories: TCategoryDto[]; regions: TRegionDto[]; + regionCategories: TRegionCategoryDto[]; }>; diff --git a/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts b/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts index f4e2213e7573..f67784b6f9d1 100644 --- a/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts +++ b/packages/manager/apps/pci-instances/src/types/catalog/entity.types.ts @@ -1,5 +1,8 @@ import { DeepReadonly } from '../utils.type'; +/** + * Model types + */ export type TModelCategory = { name: string; isNew: boolean; @@ -61,3 +64,37 @@ export type TModelEntity = DeepReadonly<{ categories: TModelCategory[]; }; }>; + +/** + * Region types + */ + +export type TRegionAvailability = 'available' | 'unavailable'; + +export type TRegionCategory = { + name: string; + isNew: boolean; +}; + +export type TRegion = { + name: string; + category: string; + datacenter: string; + isLocalzone: boolean; + isInMaintenance: boolean; + isActivated: boolean; + country: string | null; +}; + +export type TRegionsData = { + allAvailableRegions: TRegion[]; + availableMacroRegions: TRegion[]; + availableMicroRegions: TRegion[]; + unavailableRegions: TRegion[]; +}; +export type TRegionEntity = DeepReadonly<{ + regions: { + data: TRegionsData; + categories: TRegionCategory[]; + }; +}>; From 21c6ebc87dd6023021fbd570df2a334c44518cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 8 Oct 2024 16:08:41 +0200 Subject: [PATCH 49/76] test(pci-instances): enhance test suite for useCatalog hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1864 Signed-off-by: Frédéric Vilcot --- .../__mocks__/catalog/catalogGenerated3.json | 282 ++++++++++++++++++ .../__mocks__/catalog/expectedEntity3.json | 122 ++++++++ .../__mocks__/catalog/expectedEntity4.json | 179 +++++++++++ .../data/hooks/catalog/useCatalog.spec.tsx | 212 ++++++++++--- 4 files changed, 751 insertions(+), 44 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated3.json create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity3.json create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity4.json diff --git a/packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated3.json b/packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated3.json new file mode 100644 index 000000000000..c043d6b1a745 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/catalogGenerated3.json @@ -0,0 +1,282 @@ +{ + "projectId": "8c8c4fd6d4414aa29fc777752b00005198664", + "models": [ + { + "category": "balanced", + "name": "b2-30", + "isNew": false, + "compatibleLocalzone": false, + "compatibleRegion": true, + "pricings": [ + { + "regions": [ + "AP-SOUTHEAST-SYD", + "SYD1", + "UK1", + "RBX-A", + "SGP1", + "BHS5", + "DE1", + "GRA7", + "SBG7", + "GRA11", + "GRA9", + "WAW1", + "SBG5" + ], + "price": 26100000, + "interval": "hour", + "osType": "linux" + }, + { + "regions": [ + "SYD1", + "UK1", + "RBX-A", + "SGP1", + "BHS5", + "DE1", + "GRA7", + "SBG7", + "GRA11", + "GRA9", + "WAW1", + "SBG5" + ], + "price": 51600000, + "interval": "hour", + "osType": "windows" + }, + { + "regions": [ + "SYD1", + "UK1", + "RBX-A", + "SGP1", + "BHS5", + "DE1", + "GRA7", + "SBG7", + "GRA11", + "GRA9", + "WAW1", + "SBG5" + ], + "price": 9350000000, + "interval": "month", + "osType": "linux" + }, + { + "regions": [ + "SYD1", + "UK1", + "RBX-A", + "SGP1", + "BHS5", + "DE1", + "GRA7", + "SBG7", + "GRA11", + "GRA9", + "WAW1", + "SBG5" + ], + "price": 15350000000, + "interval": "month", + "osType": "windows" + } + ], + "specifications": { + "bandwidth": { + "guaranteed": true, + "level": 500, + "unlimited": false + }, + "cpu": { + "cores": 8, + "frequency": 2, + "model": "vCore", + "type": "vCore" + }, + "gpu": { + "memory": { + "interface": "", + "size": 0 + }, + "model": "", + "number": 0 + }, + "memory": { + "size": 30 + }, + "storage": { + "disks": [ + { + "capacity": 200, + "number": 0, + "technology": "SSD" + } + ], + "raid": "local" + }, + "vrack": { + "guaranteed": false, + "level": 2000, + "unlimited": false + } + }, + "banners": [] + } + ], + "categories": [ + { + "name": "compute", + "isNew": false, + "isDefault": false + }, + { + "name": "balanced", + "isNew": false, + "isDefault": true + }, + { + "name": "ram", + "isNew": false, + "isDefault": false + }, + { + "name": "accelerated", + "isNew": false, + "isDefault": false + }, + { + "name": "vps", + "isNew": false, + "isDefault": false + }, + { + "name": "discovery", + "isNew": true, + "isDefault": false + }, + { + "name": "iops", + "isNew": false, + "isDefault": false + }, + { + "name": "baremetal", + "isNew": false, + "isDefault": false + } + ], + "regions": [ + { + "name": "SYD1", + "country": "au", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "SYD", + "category": "oceania" + }, + { + "name": "EU-WEST-LZ-BRU-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "BRU", + "category": "western_europe" + }, + { + "name": "BHS5", + "country": "ca", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "BHS", + "category": "north_america" + }, + { + "name": "AP-SOUTH-MUM-1", + "country": "in", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "YNM", + "category": "south_east_asia" + }, + { + "name": "GRA7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + }, + { + "name": "SBG7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "SBG", + "category": "central_europe" + }, + { + "name": "EU-CENTRAL-LZ-PRG-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "PRG", + "category": "central_europe" + }, + { + "name": "GRA9", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + }, + { + "name": "EU-SOUTH-LZ-MIL-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "MIL", + "category": "south_europe" + } + ], + "regionCategories": [ + { + "name": "north_america", + "isNew": false + }, + { + "name": "western_europe", + "isNew": false + }, + { + "name": "central_europe", + "isNew": false + }, + { + "name": "south_europe", + "isNew": false + }, + { + "name": "south_east_asia", + "isNew": false + }, + { + "name": "oceania", + "isNew": false + } + ] +} diff --git a/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity3.json b/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity3.json new file mode 100644 index 000000000000..839b09482c2d --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity3.json @@ -0,0 +1,122 @@ +{ + "regions": { + "data": { + "allAvailableRegions": [], + "availableMacroRegions": [], + "availableMicroRegions": [], + "unavailableRegions": [ + { + "name": "SYD1", + "country": "au", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "SYD", + "category": "oceania" + }, + { + "name": "EU-WEST-LZ-BRU-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "BRU", + "category": "western_europe" + }, + { + "name": "BHS5", + "country": "ca", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "BHS", + "category": "north_america" + }, + { + "name": "AP-SOUTH-MUM-1", + "country": "in", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "YNM", + "category": "south_east_asia" + }, + { + "name": "GRA7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + }, + { + "name": "SBG7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "SBG", + "category": "central_europe" + }, + { + "name": "EU-CENTRAL-LZ-PRG-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "PRG", + "category": "central_europe" + }, + { + "name": "GRA9", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + }, + { + "name": "EU-SOUTH-LZ-MIL-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "MIL", + "category": "south_europe" + } + ] + }, + "categories": [ + { + "name": "all", + "isNew": false + }, + { + "name": "north_america", + "isNew": false + }, + { + "name": "western_europe", + "isNew": false + }, + { + "name": "central_europe", + "isNew": false + }, + { + "name": "south_europe", + "isNew": false + }, + { + "name": "south_east_asia", + "isNew": false + }, + { + "name": "oceania", + "isNew": false + } + ] + } +} diff --git a/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity4.json b/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity4.json new file mode 100644 index 000000000000..ab547ddaa54a --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/catalog/expectedEntity4.json @@ -0,0 +1,179 @@ +{ + "regions": { + "data": { + "allAvailableRegions": [ + { + "name": "SYD1", + "country": "au", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "SYD", + "category": "oceania" + }, + { + "name": "BHS5", + "country": "ca", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "BHS", + "category": "north_america" + }, + { + "name": "GRA7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + }, + { + "name": "SBG7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "SBG", + "category": "central_europe" + }, + { + "name": "GRA9", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + } + ], + "availableMacroRegions": [ + { + "name": "SYD1", + "country": "au", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "SYD", + "category": "oceania" + }, + { + "name": "BHS5", + "country": "ca", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "BHS", + "category": "north_america" + }, + { + "name": "GRA7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + }, + { + "name": "SBG7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": true, + "datacenter": "SBG", + "category": "central_europe" + } + ], + "availableMicroRegions": [ + { + "name": "GRA7", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + }, + { + "name": "GRA9", + "country": "fr", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "GRA", + "category": "western_europe" + } + ], + "unavailableRegions": [ + { + "name": "EU-WEST-LZ-BRU-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "BRU", + "category": "western_europe" + }, + { + "name": "AP-SOUTH-MUM-1", + "country": "in", + "isLocalzone": false, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "YNM", + "category": "south_east_asia" + }, + { + "name": "EU-CENTRAL-LZ-PRG-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "PRG", + "category": "central_europe" + }, + { + "name": "EU-SOUTH-LZ-MIL-A", + "country": null, + "isLocalzone": true, + "isInMaintenance": false, + "isActivated": false, + "datacenter": "MIL", + "category": "south_europe" + } + ] + }, + "categories": [ + { + "name": "all", + "isNew": false + }, + { + "name": "north_america", + "isNew": false + }, + { + "name": "western_europe", + "isNew": false + }, + { + "name": "central_europe", + "isNew": false + }, + { + "name": "south_europe", + "isNew": false + }, + { + "name": "south_east_asia", + "isNew": false + }, + { + "name": "oceania", + "isNew": false + } + ] + } +} diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx index 8265b5d8569b..ba828b9c2b5e 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx @@ -1,18 +1,22 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { FC, PropsWithChildren } from 'react'; import { describe, expect, test, afterEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { SetupServer } from 'msw/lib/node'; import { AxiosError } from 'axios'; -import { modelSelector, TModelSelector, useCatalog } from './useCatalog'; -import { TModelEntity } from '@/types/catalog/entity.types'; +import { TUseCatalogSelector, useCatalog } from './useCatalog'; +import { TModelEntity, TRegionEntity } from '@/types/catalog/entity.types'; import { setupCatalogServer } from '@/__mocks__/catalog/node'; import { instancesQueryKey } from '@/utils'; import mockedCatalog1 from '@/__mocks__/catalog/catalogGenerated1.json'; import mockedCatalog2 from '@/__mocks__/catalog/catalogGenerated2.json'; +import mockedCatalog3 from '@/__mocks__/catalog/catalogGenerated3.json'; import { TCatalogDto } from '@/types/catalog/api.types'; import expectedEntity1 from '@/__mocks__/catalog/expectedEntity1.json'; import expectedEntity2 from '@/__mocks__/catalog/expectedEntity2.json'; +import expectedEntity3 from '@/__mocks__/catalog/expectedEntity3.json'; +import expectedEntity4 from '@/__mocks__/catalog/expectedEntity4.json'; +import { useAppStore } from '@/store/hooks/useAppStore'; // initializers const initQueryClient = () => { @@ -29,56 +33,176 @@ const initQueryClient = () => { return { wrapper, queryClient }; }; -// test data -type Data = { - projectId: string; - selector: TModelSelector; - queryPayload: TCatalogDto; - expectedModelEntity: TModelEntity; +// helpers +const expectStrictEqual = async (result: any, expected: any) => { + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toStrictEqual(expected); }; +// global data const fakeProjectId = 'p42b4f068f404ef3832435304a316332'; +const emptyCatalog: TCatalogDto = { + projectId: fakeProjectId, + models: [], + categories: [], + regionCategories: [], + regions: [], +}; // msw server let server: SetupServer; -describe('useCatalog hook', () => { - describe.each` - projectId | queryPayload | selector | expectedModelEntity - ${fakeProjectId} | ${undefined} | ${modelSelector} | ${undefined} - ${fakeProjectId} | ${{ models: [], categories: [] }} | ${modelSelector} | ${{ models: { data: [], categories: [] } }} - ${fakeProjectId} | ${mockedCatalog1} | ${modelSelector} | ${expectedEntity1} - ${fakeProjectId} | ${mockedCatalog2} | ${modelSelector} | ${expectedEntity2} - `( - 'Given a projectId <$projectId> and a selector <$selector>', - ({ projectId, selector, queryPayload, expectedModelEntity }: Data) => { - afterEach(() => { - server?.close(); - }); - test(`When invoking useCatalog() hook', then, expect the computed model entity to be '${JSON.stringify( - expectedModelEntity, - )}'`, async () => { - server = setupCatalogServer(queryPayload); +describe('Considering the useCatalog() hook', () => { + describe('When invoked with no selector', () => { + // test data + type Data = { + projectId: string; + queryPayload?: TCatalogDto; + expectedRawData: TCatalogDto; + enabled: boolean; + }; + describe.each` + projectId | queryPayload | expectedRawData | enabled + ${fakeProjectId} | ${undefined} | ${undefined} | ${true} + ${fakeProjectId} | ${mockedCatalog3} | ${mockedCatalog3} | ${true} + ${fakeProjectId} | ${mockedCatalog3} | ${undefined} | ${false} + `( + 'Given a projectId <$projectId> and a enabled option parameter <$enabled>', + ({ projectId, queryPayload, expectedRawData, enabled }: Data) => { + afterEach(() => { + server?.close(); + }); + test('When invoking useCatalog() hook, then, expect to receive raw data', async () => { + server = setupCatalogServer(queryPayload); + + const { wrapper, queryClient } = initQueryClient(); + const { result } = renderHook( + () => + useCatalog(projectId, { + enabled, + }), + { + wrapper, + }, + ); + const queryCache = queryClient.getQueryCache(); + if (!enabled) { + await waitFor(() => expect(result.current.isPending).toBe(true)); + } else if (!queryPayload) { + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toHaveProperty('response.status', 500); + expect(result.current.error).instanceOf(AxiosError); + } else { + expectStrictEqual(result, expectedRawData); + expect( + queryCache.getAll().map((cache) => cache.queryKey)[0], + ).toStrictEqual(instancesQueryKey(projectId, ['catalog'])); + } + }); + }, + ); + }); - const { wrapper, queryClient } = initQueryClient(); - const { result } = renderHook(() => useCatalog(projectId, selector), { + describe('When invoked with a forbidden selector (e.g. useCatalog("foo", { selector: "bar" as TUseCatalogSelector })', () => { + afterAll(() => { + server?.close(); + }); + test('When invoking useCatalog() hook, then, expect to receive raw data', async () => { + server = setupCatalogServer(mockedCatalog1); + + const { wrapper } = initQueryClient(); + const { result } = renderHook( + () => + useCatalog(fakeProjectId, { + selector: 'foo' as TUseCatalogSelector, + }), + { wrapper, + }, + ); + await expectStrictEqual(result, mockedCatalog1); + }); + }); + + describe('When invoked with model selector', () => { + // test data + type Data = { + projectId: string; + queryPayload: TCatalogDto; + expectedModelEntity: TModelEntity; + }; + describe.each` + projectId | queryPayload | expectedModelEntity + ${fakeProjectId} | ${emptyCatalog} | ${{ models: { data: [], categories: [] } }} + ${fakeProjectId} | ${mockedCatalog1} | ${expectedEntity1} + ${fakeProjectId} | ${mockedCatalog2} | ${expectedEntity2} + `( + 'Given a projectId <$projectId>', + ({ projectId, queryPayload, expectedModelEntity }: Data) => { + afterEach(() => { + server?.close(); }); - const queryCache = queryClient.getQueryCache(); + test("When invoking useCatalog() hook', then, expect the model entity to have been computed", async () => { + server = setupCatalogServer(queryPayload); - expect(result.current.isPending).toBe(true); - if (queryPayload) { - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - } else { - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toHaveProperty('response.status', 500); - expect(result.current.error).instanceOf(AxiosError); - } - expect(result.current.data).toStrictEqual(expectedModelEntity); - expect( - queryCache.getAll().map((cache) => cache.queryKey)[0], - ).toStrictEqual(instancesQueryKey(projectId, ['catalog'])); - }); - }, - ); + const { wrapper } = initQueryClient(); + const { result } = renderHook( + () => + useCatalog<'modelSelector'>(projectId, { + selector: 'modelSelector', + }), + { + wrapper, + }, + ); + await expectStrictEqual(result, expectedModelEntity); + }); + }, + ); + }); + + describe('When invoked with region selector', () => { + // test data + type Data = { + projectId: string; + modelName: string; + queryPayload: TCatalogDto; + expectedRegionEntity: TRegionEntity; + }; + describe.each` + projectId | modelName | queryPayload | expectedRegionEntity + ${fakeProjectId} | ${undefined} | ${emptyCatalog} | ${undefined} + ${fakeProjectId} | ${'b3-8'} | ${emptyCatalog} | ${undefined} + ${fakeProjectId} | ${undefined} | ${mockedCatalog3} | ${undefined} + ${fakeProjectId} | ${'b3-8'} | ${mockedCatalog3} | ${expectedEntity3} + ${fakeProjectId} | ${'b2-30'} | ${mockedCatalog3} | ${expectedEntity4} + `( + 'Given a projectId <$projectId> and a modelName <$modelName>', + ({ projectId, queryPayload, expectedRegionEntity, modelName }: Data) => { + afterEach(() => { + server?.close(); + }); + test(`When invoking useCatalog() hook', then, expect the region entity to have been computed`, async () => { + server = setupCatalogServer(queryPayload); + + const { wrapper } = initQueryClient(); + const { result: appStoreResult } = renderHook(() => useAppStore()); + act(() => { + appStoreResult.current.setModelName(modelName); + }); + + const { result } = renderHook( + () => + useCatalog<'regionSelector'>(projectId, { + selector: 'regionSelector', + }), + { + wrapper, + }, + ); + await expectStrictEqual(result, expectedRegionEntity); + }); + }, + ); + }); }); From 6899d8d094f059fd86b661d9083efee75f31f0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 8 Oct 2024 16:11:04 +0200 Subject: [PATCH 50/76] feat(pci-instances): add useActivateRegion hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1864 Signed-off-by: Frédéric Vilcot --- .../apps/pci-instances/src/data/api/region.ts | 10 +++++ .../data/hooks/region/useActivateRegion.ts | 38 ++++++++++++++++ .../src/types/region/api.types.ts | 44 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/data/api/region.ts create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.ts create mode 100644 packages/manager/apps/pci-instances/src/types/region/api.types.ts diff --git a/packages/manager/apps/pci-instances/src/data/api/region.ts b/packages/manager/apps/pci-instances/src/data/api/region.ts new file mode 100644 index 000000000000..7022a506e084 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/api/region.ts @@ -0,0 +1,10 @@ +import { v6 } from '@ovh-ux/manager-core-api'; +import { TActivatedRegionDto } from '@/types/region/api.types'; + +export const activateRegion = ( + projectId: string, + region: string, +): Promise => + v6 + .post(`/cloud/project/${projectId}/region`, { region }) + .then((response) => response.data); diff --git a/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.ts b/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.ts new file mode 100644 index 000000000000..a4bfdd849a18 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.ts @@ -0,0 +1,38 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { DeepReadonly } from '@/types/utils.type'; +import { activateRegion } from '@/data/api/region'; +import { TActivatedRegionDto } from '@/types/region/api.types'; + +export type TUseActivateRegionCallbacks = DeepReadonly<{ + onSuccess?: (region: string) => void; + onError?: (error: unknown) => void; +}>; + +export const useActivateRegion = ( + projectId: string, + { onError, onSuccess }: TUseActivateRegionCallbacks = {}, +) => { + const mutationFn = useCallback( + (region: string) => activateRegion(projectId, region), + [projectId], + ); + + const handleSuccess = useCallback( + (data: TActivatedRegionDto) => { + if (onSuccess) onSuccess(data.name); + }, + [onSuccess], + ); + + const mutation = useMutation({ + mutationFn, + onError, + onSuccess: handleSuccess, + }); + + return { + activateRegion: mutation.mutate, + ...mutation, + }; +}; diff --git a/packages/manager/apps/pci-instances/src/types/region/api.types.ts b/packages/manager/apps/pci-instances/src/types/region/api.types.ts new file mode 100644 index 000000000000..63deb2a8d0b2 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/types/region/api.types.ts @@ -0,0 +1,44 @@ +import { DeepReadonly } from '../utils.type'; + +type TContinentCode = 'ASIA ' | 'EU' | 'NA' | 'US'; + +type TIpCountries = + | 'au' + | 'be' + | 'ca' + | 'cz' + | 'de' + | 'es' + | 'fi' + | 'fr' + | 'ie' + | 'in' + | 'it' + | 'lt' + | 'nl' + | 'pl' + | 'pt' + | 'sg' + | 'uk' + | 'us'; + +type TRegionStatus = 'DOWN' | 'MAINTENANCE' | 'UP'; +type TRegionServiceStatus = Omit; + +type TRegionType = 'localzone' | 'region' | 'region-3-az'; + +type TRegionServiceDto = { + name: string; + status: TRegionServiceStatus; + endpoint: string; +}; + +export type TActivatedRegionDto = DeepReadonly<{ + name: string; + continentCode: TContinentCode; + datacenterLocation: string; + status: TRegionStatus; + type: TRegionType; + ipCountries: TIpCountries[]; + services: TRegionServiceDto[]; +}>; From 087a15cec5e633e404e8595ffdfbb6621470dec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 8 Oct 2024 16:12:08 +0200 Subject: [PATCH 51/76] test(pci-instances): add test suite for useActivateRegion hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1864 Signed-off-by: Frédéric Vilcot --- .../src/__mocks__/region/handlers.ts | 15 +++ .../src/__mocks__/region/node.ts | 13 ++ .../src/__mocks__/region/regionActivated.json | 30 +++++ .../data/hooks/catalog/useCatalog.spec.tsx | 40 +++---- .../hooks/region/useActivateRegion.spec.tsx | 112 ++++++++++++++++++ .../create/steps/ModelsStep.component.tsx | 9 +- 6 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/region/handlers.ts create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/region/node.ts create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/region/regionActivated.json create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.spec.tsx diff --git a/packages/manager/apps/pci-instances/src/__mocks__/region/handlers.ts b/packages/manager/apps/pci-instances/src/__mocks__/region/handlers.ts new file mode 100644 index 000000000000..e09ae9ce392f --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/region/handlers.ts @@ -0,0 +1,15 @@ +import { http, HttpResponse, JsonBodyType, RequestHandler } from 'msw'; +import mockedCatalog3 from '@/__mocks__/catalog/catalogGenerated3.json'; + +export const regionHandlers = ( + mockedResponsePayload?: T, +): RequestHandler[] => [ + http.post('*/cloud/project/:projectId/region', async () => { + return !mockedResponsePayload + ? new HttpResponse(null, { status: 500 }) + : HttpResponse.json(mockedResponsePayload); + }), + http.get('*/cloud/project/:projectId/catalog/instance', async () => { + return HttpResponse.json(mockedCatalog3); + }), +]; diff --git a/packages/manager/apps/pci-instances/src/__mocks__/region/node.ts b/packages/manager/apps/pci-instances/src/__mocks__/region/node.ts new file mode 100644 index 000000000000..57622dd05fae --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/region/node.ts @@ -0,0 +1,13 @@ +import { setupServer } from 'msw/node'; +import { JsonBodyType } from 'msw'; +import { regionHandlers } from './handlers'; + +export const setupRegionServer = ( + mockedResponsePayload?: T, +) => { + const server = setupServer(...regionHandlers(mockedResponsePayload)); + server.listen({ + onUnhandledRequest: 'error', + }); + return server; +}; diff --git a/packages/manager/apps/pci-instances/src/__mocks__/region/regionActivated.json b/packages/manager/apps/pci-instances/src/__mocks__/region/regionActivated.json new file mode 100644 index 000000000000..f6223900cb98 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/region/regionActivated.json @@ -0,0 +1,30 @@ +{ + "name": "EU-CENTRAL-LZ-PRG-A", + "continentCode": "EU", + "datacenterLocation": "PRG", + "status": "UP", + "type": "localzone", + "services": [ + { + "name": "volume", + "status": "UP", + "endpoint": "https://foo/bar.fake" + }, + { + "name": "network", + "status": "UP", + "endpoint": "https://foo/bar2.fake" + }, + { + "name": "instance", + "status": "UP", + "endpoint": "https://foo/bar3.fake" + }, + { + "name": "image", + "status": "UP", + "endpoint": "https://foo/bar4.fake" + } + ], + "ipCountries": [] +} diff --git a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx index ba828b9c2b5e..a81db7171943 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/catalog/useCatalog.spec.tsx @@ -8,9 +8,9 @@ import { TUseCatalogSelector, useCatalog } from './useCatalog'; import { TModelEntity, TRegionEntity } from '@/types/catalog/entity.types'; import { setupCatalogServer } from '@/__mocks__/catalog/node'; import { instancesQueryKey } from '@/utils'; -import mockedCatalog1 from '@/__mocks__/catalog/catalogGenerated1.json'; -import mockedCatalog2 from '@/__mocks__/catalog/catalogGenerated2.json'; -import mockedCatalog3 from '@/__mocks__/catalog/catalogGenerated3.json'; +import mockedCatalogWithMultipleModelPrices from '@/__mocks__/catalog/catalogGenerated1.json'; +import mockedCatalogWithGPUModel from '@/__mocks__/catalog/catalogGenerated2.json'; +import mockedCatalogWithMultipleRegions from '@/__mocks__/catalog/catalogGenerated3.json'; import { TCatalogDto } from '@/types/catalog/api.types'; import expectedEntity1 from '@/__mocks__/catalog/expectedEntity1.json'; import expectedEntity2 from '@/__mocks__/catalog/expectedEntity2.json'; @@ -62,10 +62,10 @@ describe('Considering the useCatalog() hook', () => { enabled: boolean; }; describe.each` - projectId | queryPayload | expectedRawData | enabled - ${fakeProjectId} | ${undefined} | ${undefined} | ${true} - ${fakeProjectId} | ${mockedCatalog3} | ${mockedCatalog3} | ${true} - ${fakeProjectId} | ${mockedCatalog3} | ${undefined} | ${false} + projectId | queryPayload | expectedRawData | enabled + ${fakeProjectId} | ${undefined} | ${undefined} | ${true} + ${fakeProjectId} | ${mockedCatalogWithMultipleRegions} | ${mockedCatalogWithMultipleRegions} | ${true} + ${fakeProjectId} | ${mockedCatalogWithMultipleRegions} | ${undefined} | ${false} `( 'Given a projectId <$projectId> and a enabled option parameter <$enabled>', ({ projectId, queryPayload, expectedRawData, enabled }: Data) => { @@ -93,7 +93,7 @@ describe('Considering the useCatalog() hook', () => { expect(result.current.error).toHaveProperty('response.status', 500); expect(result.current.error).instanceOf(AxiosError); } else { - expectStrictEqual(result, expectedRawData); + await expectStrictEqual(result, expectedRawData); expect( queryCache.getAll().map((cache) => cache.queryKey)[0], ).toStrictEqual(instancesQueryKey(projectId, ['catalog'])); @@ -108,7 +108,7 @@ describe('Considering the useCatalog() hook', () => { server?.close(); }); test('When invoking useCatalog() hook, then, expect to receive raw data', async () => { - server = setupCatalogServer(mockedCatalog1); + server = setupCatalogServer(mockedCatalogWithMultipleModelPrices); const { wrapper } = initQueryClient(); const { result } = renderHook( @@ -120,7 +120,7 @@ describe('Considering the useCatalog() hook', () => { wrapper, }, ); - await expectStrictEqual(result, mockedCatalog1); + await expectStrictEqual(result, mockedCatalogWithMultipleModelPrices); }); }); @@ -132,10 +132,10 @@ describe('Considering the useCatalog() hook', () => { expectedModelEntity: TModelEntity; }; describe.each` - projectId | queryPayload | expectedModelEntity - ${fakeProjectId} | ${emptyCatalog} | ${{ models: { data: [], categories: [] } }} - ${fakeProjectId} | ${mockedCatalog1} | ${expectedEntity1} - ${fakeProjectId} | ${mockedCatalog2} | ${expectedEntity2} + projectId | queryPayload | expectedModelEntity + ${fakeProjectId} | ${emptyCatalog} | ${{ models: { data: [], categories: [] } }} + ${fakeProjectId} | ${mockedCatalogWithMultipleModelPrices} | ${expectedEntity1} + ${fakeProjectId} | ${mockedCatalogWithGPUModel} | ${expectedEntity2} `( 'Given a projectId <$projectId>', ({ projectId, queryPayload, expectedModelEntity }: Data) => { @@ -170,12 +170,12 @@ describe('Considering the useCatalog() hook', () => { expectedRegionEntity: TRegionEntity; }; describe.each` - projectId | modelName | queryPayload | expectedRegionEntity - ${fakeProjectId} | ${undefined} | ${emptyCatalog} | ${undefined} - ${fakeProjectId} | ${'b3-8'} | ${emptyCatalog} | ${undefined} - ${fakeProjectId} | ${undefined} | ${mockedCatalog3} | ${undefined} - ${fakeProjectId} | ${'b3-8'} | ${mockedCatalog3} | ${expectedEntity3} - ${fakeProjectId} | ${'b2-30'} | ${mockedCatalog3} | ${expectedEntity4} + projectId | modelName | queryPayload | expectedRegionEntity + ${fakeProjectId} | ${undefined} | ${emptyCatalog} | ${undefined} + ${fakeProjectId} | ${'b3-8'} | ${emptyCatalog} | ${undefined} + ${fakeProjectId} | ${undefined} | ${mockedCatalogWithMultipleRegions} | ${undefined} + ${fakeProjectId} | ${'b3-8'} | ${mockedCatalogWithMultipleRegions} | ${expectedEntity3} + ${fakeProjectId} | ${'b2-30'} | ${mockedCatalogWithMultipleRegions} | ${expectedEntity4} `( 'Given a projectId <$projectId> and a modelName <$modelName>', ({ projectId, queryPayload, expectedRegionEntity, modelName }: Data) => { diff --git a/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.spec.tsx new file mode 100644 index 000000000000..c2aaa0835a83 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.spec.tsx @@ -0,0 +1,112 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SetupServer } from 'msw/lib/node'; +import { FC, PropsWithChildren } from 'react'; +import { describe, test, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import { setupRegionServer } from '@/__mocks__/region/node'; +import { useActivateRegion } from './useActivateRegion'; +import regionActivated from '@/__mocks__/region/regionActivated.json'; +import { TCatalogDto } from '@/types/catalog/api.types'; +import { updateCatalogQueryData, useCatalog } from '../catalog/useCatalog'; +import { TActivatedRegionDto } from '@/types/region/api.types'; + +// initializers +const initQueryClient = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const wrapper: FC = ({ children }) => ( + {children} + ); + return { wrapper, queryClient }; +}; + +// test data +type Data = { + projectId: string; + regionName: string; + mutationPayload?: TActivatedRegionDto; +}; + +const fakeProjectId = '8c8c4fd6d4414aa29fc777752b00005198664'; + +// msw server +let server: SetupServer; + +// mocks +const handleError = vi.fn(); +const handleSuccess = vi.fn( + (projectId: string, queryClient: QueryClient) => (name: string) => + updateCatalogQueryData(queryClient, projectId, name), +); + +describe('Considering the useActivateRegion hook', () => { + describe.each` + projectId | regionName | mutationPayload + ${fakeProjectId} | ${''} | ${undefined} + ${fakeProjectId} | ${'EU-CENTRAL-LZ-PRG-A'} | ${undefined} + ${fakeProjectId} | ${'EU-CENTRAL-LZ-PRG-A'} | ${regionActivated} + `( + 'Given a projectId <$projectId> and a regionName <$regionName>', + ({ projectId, regionName, mutationPayload }: Data) => { + afterEach(() => { + server?.close(); + }); + test("When invoking activateRegion() mutate's function", async () => { + server = setupRegionServer(mutationPayload); + + const { wrapper, queryClient } = initQueryClient(); + + const { result: useCatalogResult } = renderHook( + () => useCatalog(projectId), + { + wrapper, + }, + ); + + const { result: useActivateRegionResult } = renderHook( + () => + useActivateRegion(projectId, { + onSuccess: handleSuccess(projectId, queryClient), + onError: handleError, + }), + { + wrapper, + }, + ); + + expect(useActivateRegionResult.current.isIdle).toBeTruthy(); + + act(() => useActivateRegionResult.current.activateRegion(regionName)); + + if (!mutationPayload || !regionName.length) { + await waitFor(() => + expect(useActivateRegionResult.current.isError).toBeTruthy(), + ); + expect(useActivateRegionResult.current.error).toHaveProperty( + 'response.status', + 500, + ); + expect(useActivateRegionResult.current.error).instanceOf(AxiosError); + expect(handleError).toHaveBeenCalled(); + } else { + await waitFor(() => + expect(useActivateRegionResult.current.isSuccess).toBeTruthy(), + ); + const activatedRegion = (useCatalogResult.current + .data as TCatalogDto).regions.find( + (region) => region.name === regionName, + ); + expect(handleSuccess).toHaveBeenCalled(); + expect(activatedRegion).toBeDefined(); + expect(activatedRegion?.isActivated).toBeTruthy(); + } + }); + }, + ); +}); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx index e2028aa27f05..0d25a997e8d6 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx @@ -16,7 +16,7 @@ import { ODS_TEXT_LEVEL, ODS_TEXT_SIZE, } from '@ovhcloud/ods-components'; -import { modelSelector, useCatalog } from '@/data/hooks/catalog/useCatalog'; +import { useCatalog } from '@/data/hooks/catalog/useCatalog'; import { Spinner } from '@/components/spinner/Spinner.component'; import { TModelCategory, @@ -26,7 +26,6 @@ import { TStorage, } from '@/types/catalog/entity.types'; import { DeepReadonly } from '@/types/utils.type'; -import '@ovh-ux/manager-pci-common/src/components/flavor-selector/translations/index'; import { useAppStore } from '@/store/hooks/useAppStore'; import { TStep, TStepId } from '@/store/slices/stepper.slice'; @@ -34,10 +33,12 @@ const modelStepId: TStepId = 'model'; const validatedModelStepState: Partial = { isChecked: true, isLocked: true, + isOpen: false, }; const editedModelStepState: Partial = { isChecked: false, isLocked: false, + isOpen: true, }; type TTabProps = { @@ -172,7 +173,9 @@ const TabTitle: FC = ({ t, category, isSelected }) => ( export const ModelsStep: FC = () => { const { projectId } = useParams() as { projectId: string }; const { t } = useTranslation(['create', 'stepper']); - const { data, isLoading } = useCatalog(projectId, modelSelector); + const { data, isLoading } = useCatalog<'modelSelector'>(projectId, { + selector: 'modelSelector', + }); const { stepStateById, modelName, setModelName, updateStep } = useAppStore( useShallow((state) => ({ stepStateById: state.stepStateById(), From 5e729386049a9756c123a43b376aebda250a1f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 11 Oct 2024 16:06:43 +0200 Subject: [PATCH 52/76] feat(pci-instances): add tabs & tiles components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1898 Signed-off-by: Frédéric Vilcot --- .../breadcrumb/Breadcrumb.component.tsx | 2 +- .../tab/TabContentWrapper.component.tsx | 26 ++++++++ .../src/components/tab/TabTitle.component.tsx | 61 +++++++++++++++++++ .../components/tile/RegionTile.component.tsx | 61 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/tab/TabTitle.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/tile/RegionTile.component.tsx diff --git a/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx b/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx index 0a9606cc12de..12322d970c2d 100644 --- a/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/breadcrumb/Breadcrumb.component.tsx @@ -31,7 +31,7 @@ export const Breadcrumb: FC = ({ }, { href: backHref, - label: t('instances_title'), + label: t('pci_instances_common_instances_title'), }, ...items, ]} diff --git a/packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.component.tsx b/packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.component.tsx new file mode 100644 index 000000000000..749b044f64b2 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.component.tsx @@ -0,0 +1,26 @@ +import { ODS_TEXT_LEVEL, ODS_TEXT_SIZE } from '@ovhcloud/ods-components'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { OsdsText } from '@ovhcloud/ods-components/react'; +import { FC, PropsWithChildren } from 'react'; +import { DeepReadonly } from '@/types/utils.type'; + +export type TTabContentWrapperProps = DeepReadonly<{ + description?: string; +}>; + +export const TabContentWrapper: FC> = ({ description, children }) => ( +
+ {description && ( + + {description} + + )} + {children} +
+); diff --git a/packages/manager/apps/pci-instances/src/components/tab/TabTitle.component.tsx b/packages/manager/apps/pci-instances/src/components/tab/TabTitle.component.tsx new file mode 100644 index 000000000000..f2d884975dd7 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/tab/TabTitle.component.tsx @@ -0,0 +1,61 @@ +import { FC } from 'react'; +import { OsdsChip, OsdsText } from '@ovhcloud/ods-components/react'; +import { + ODS_THEME_TYPOGRAPHY_SIZE, + ODS_THEME_COLOR_INTENT, +} from '@ovhcloud/ods-common-theming'; +import clsx from 'clsx'; +import { + ODS_CHIP_SIZE, + ODS_TEXT_LEVEL, + ODS_TEXT_SIZE, +} from '@ovhcloud/ods-components'; +import { useTranslation } from 'react-i18next'; +import { DeepReadonly } from '@/types/utils.type'; + +export type TTabTitleProps = DeepReadonly<{ + label: string; + isSelected: boolean; + isNew?: boolean; +}>; + +export const TabTitle: FC = ({ isSelected, label, isNew }) => { + const { t } = useTranslation('common'); + return ( + +
+ + {label} + + {isNew && ( + + + {t('pci_instances_common_new')} + + + )} +
+
+ ); +}; diff --git a/packages/manager/apps/pci-instances/src/components/tile/RegionTile.component.tsx b/packages/manager/apps/pci-instances/src/components/tile/RegionTile.component.tsx new file mode 100644 index 000000000000..3701a808e0fd --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/tile/RegionTile.component.tsx @@ -0,0 +1,61 @@ +import { RegionGlobalzoneChip } from '@ovh-ux/manager-pci-common/src/components/region-selector/RegionGlobalzoneChip.component'; +import { RegionLocalzoneChip } from '@ovh-ux/manager-pci-common/src/components/region-selector/RegionLocalzoneChip.component'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { OsdsTile, OsdsText } from '@ovhcloud/ods-components/react'; +import clsx from 'clsx'; +import { FC } from 'react'; +import { ODS_TEXT_LEVEL, ODS_TEXT_SIZE } from '@ovhcloud/ods-components'; +import { DeepReadonly } from '@/types/utils.type'; + +const blue100Var = '[--ods-color-blue-100]'; +const blue600Var = '[--ods-color-blue-600]'; +const borderBlue100 = `border-${blue100Var}`; +const borderBlue600 = `border-${blue600Var}`; +const baseClassName = `cursor-pointer ${borderBlue100} hover:bg-${blue100Var} hover:${borderBlue600}`; +const selectedClassName = `font-bold bg-${blue100Var} ${borderBlue600}`; +const disabledClassName = borderBlue100; + +export type TRegionTileProps = DeepReadonly<{ + label: string; + onTileClick?: () => void; + isLocalzone?: boolean; + isSelected?: boolean; + isDisabled?: boolean; +}>; + +export const RegionTile: FC = ({ + label, + isLocalzone, + onTileClick, + isSelected, + isDisabled, +}) => ( + +
+
+ + {label} + +
+ {isLocalzone !== undefined && ( + <> +
+
+ {isLocalzone ? : } +
+ + )} +
+
+); From 355e965ea7a677640e6223dd240196159b4e9394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 11 Oct 2024 16:07:52 +0200 Subject: [PATCH 53/76] feat(pci-instances): add new translation files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1898 Signed-off-by: Frédéric Vilcot --- .../translations/common/Messages_fr_FR.json | 6 +++-- .../translations/create/Messages_fr_FR.json | 19 -------------- .../translations/models/Messages_fr_FR.json | 21 +++++++++++++++ .../translations/regions/Messages_fr_FR.json | 26 +++++++++++++++++++ .../translations/stepper/Messages_fr_FR.json | 4 +-- 5 files changed, 53 insertions(+), 23 deletions(-) delete mode 100644 packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json create mode 100644 packages/manager/apps/pci-instances/public/translations/models/Messages_fr_FR.json create mode 100644 packages/manager/apps/pci-instances/public/translations/regions/Messages_fr_FR.json diff --git a/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json index 965c5617a783..da2ed9650b89 100644 --- a/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json @@ -1,4 +1,6 @@ { - "instances_title": "Instances", - "create_instance": "Créer une instance" + "pci_instances_common_instances_title": "Instances", + "pci_instances_common_create_instance": "Créer une instance", + "pci_instances_common_new": "Nouveau", + "pci_instances_common_go_back": "Revenir à la page précédente" } diff --git a/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json deleted file mode 100644 index 54c10222cbf9..000000000000 --- a/packages/manager/apps/pci-instances/public/translations/create/Messages_fr_FR.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "select_template": "Sélectionnez un modèle", - "new": "Nouveau", - "go_back": "Revenir à la page précédente", - "model_category_balanced_title": "General Purpose", - "model_category_compute_title": "Compute Optimized", - "model_category_ram_title": "Memory Optimized", - "model_category_accelerated_title": "GPU", - "model_category_discovery_title": "Discovery", - "model_category_iops_title": "Storage Optimized", - "model_category_baremetal_title": "Metal", - "model_category_balanced_description": "Les instances à usage général offrent un équilibre entre RAM et performances.", - "model_category_compute_description": "Les instances de calcul optimisé sont idéales pour les applications nécessitant des fréquences de calculs importantes ou de la parallélisation de tâches.", - "model_category_ram_description": "Les instances à mémoire optimisée sont recommandées pour vos bases de données, analyses et calculs en mémoire, ainsi que d'autres applicatifs gourmands en RAM.", - "model_category_accelerated_description": "Les instances de calcul accéléré (GPU, FPGA) sont jusqu'à 1 000 fois plus rapides qu'un CPU sur certaines applications (rendering, transcodage vidéo, bio-informatique, Big Data, deep learning, etc.)", - "model_category_discovery_description": "Les instances à ressources partagées (Discovery) sont adaptées aux tests, recettes et environnements de développement. Leurs performances peuvent légèrement varier au cours du temps.", - "model_category_iops_description": "Les instances IOPS livrent les transactions disque les plus rapides de la gamme Public Cloud.", - "model_category_baremetal_description": "Les instances metal proposent des serveurs physiques à la demande, livrés en quelques minutes et facturés à l'heure ou au mois." -} diff --git a/packages/manager/apps/pci-instances/public/translations/models/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/models/Messages_fr_FR.json new file mode 100644 index 000000000000..031370567013 --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/models/Messages_fr_FR.json @@ -0,0 +1,21 @@ +{ + "pci_instances_models_select_template": "Sélectionnez un modèle", + "pci_instances_models_category_balanced_title": "General Purpose", + "pci_instances_models_category_compute_title": "Compute Optimized", + "pci_instances_models_category_ram_title": "Memory Optimized", + "pci_instances_models_category_accelerated_title": "GPU", + "pci_instances_models_category_discovery_title": "Discovery", + "pci_instances_models_category_iops_title": "Storage Optimized", + "pci_instances_models_category_baremetal_title": "Metal", + "pci_instances_models_category_balanced_description": "Les instances à usage général offrent un équilibre entre RAM et performances.", + "pci_instances_models_category_compute_description": "Les instances de calcul optimisé sont idéales pour les applications nécessitant des fréquences de calculs importantes ou de la parallélisation de tâches.", + "pci_instances_models_category_ram_description": "Les instances à mémoire optimisée sont recommandées pour vos bases de données, analyses et calculs en mémoire, ainsi que d'autres applicatifs gourmands en RAM.", + "pci_instances_models_category_accelerated_description": "Les instances de calcul accéléré (GPU, FPGA) sont jusqu'à 1 000 fois plus rapides qu'un CPU sur certaines applications (rendering, transcodage vidéo, bio-informatique, Big Data, deep learning, etc.)", + "pci_instances_models_category_discovery_description": "Les instances à ressources partagées (Discovery) sont adaptées aux tests, recettes et environnements de développement. Leurs performances peuvent légèrement varier au cours du temps.", + "pci_instances_models_category_iops_description": "Les instances IOPS livrent les transactions disque les plus rapides de la gamme Public Cloud.", + "pci_instances_models_category_baremetal_description": "Les instances metal proposent des serveurs physiques à la demande, livrés en quelques minutes et facturés à l'heure ou au mois.", + "pci_instances_models_chosen_model_message": "Modèle choisi : {{ model }}", + "pci_instances_models_monthly_price_excl_vat": "à partir de {{ price }} HT/mois", + "pci_instances_models_error_message1": "Nous sommes désolés, une erreur est survenue et la requête n'a pas pu être traitée. Vous pouvez réessayer de Rafraîchir la requête.", + "pci_instances_models_error_message2": "Si le problème persiste, merci de contacter le support technique." +} diff --git a/packages/manager/apps/pci-instances/public/translations/regions/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/regions/Messages_fr_FR.json new file mode 100644 index 000000000000..49da81f2f02b --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/regions/Messages_fr_FR.json @@ -0,0 +1,26 @@ +{ + "pci_instances_regions_select_localization": "Sélectionnez une localisation", + "pci_instances_regions_category_all_title": "Toutes les localisations", + "pci_instances_regions_category_north_america_title": "Amérique du Nord", + "pci_instances_regions_category_western_europe_title": "Europe de l'Ouest", + "pci_instances_regions_category_central_europe_title": "Europe centrale", + "pci_instances_regions_category_south_europe_title": "Europe du Sud", + "pci_instances_regions_category_south_east_asia_title": "Asie Pacifique", + "pci_instances_regions_category_oceania_title": "Océanie", + "pci_instances_regions_location": "Localisation", + "pci_instances_regions_chosen_region_message": "Localisation choisie : {{ region }}", + "pci_instances_regions_activation_loading_message": "La localisation {{ region }} est en cours d'ajout à votre projet. Veuillez patienter quelques instants.", + "pci_instances_regions_activation_success_message": "La localisation {{ region }} a bien été ajoutée à votre projet.", + "pci_instances_regions_activation_error_message": "La région {{ region }} n'a pas pu être ajoutée à votre projet. Cliquer sur suivant pour réessayer.", + "pci_instances_regions_label": "Régions", + "pci_instances_regions_tooltip": "Les Régions sont supportées par un ou plusieurs datacenters gérés par OVHCloud. Chaque région fournit une ou plusieurs Availability Zone avec le portefeuille complet de services OVHCloud.", + "pci_instances_regions_tooltip_link": "En savoir plus", + "pci_instances_regions_not_activated_message": "La localisation sélectionnée n’est pas activée. Cliquez sur « Suivant » pour l’ajouter à votre projet Public Cloud et continuer la création d’instances.", + "pci_instances_regions_show_unavailable_regions_message": "Montrer les localisations indisponibles", + "pci_instances_regions_other_models_regions_availability_message": "Ces localisations sont disponibles sur d'autre modèles.", + "pci_instances_regions_other_models_region_availability_message": "Cette localisation est disponible sur d'autre modèles.", + "pci_instances_regions_local_zones_label": "Local Zones", + "pci_instances_regions_local_zones_tooltip": "Les Local Zones sont un nouveau type de localisation, qui prennent en charge une partie de notre portefeuille de produits Public Cloud. Nous allons progressivement augmenter le nombre total de Local Zones dans le monde au cours des prochaines années.", + "pci_instances_regions_extra_coast_message": "Le trafic réseau public sortant des instances est inclus pour toutes les localisations, à l’exception de celles en région Asie-Pacifique et Océanie. Dans ces deux régions, 1 To/mois de trafic public sortant est inclus avec chaque projet Public Cloud. Au-delà de ce quota, chaque Go de trafic supplémentaire est facturé 0,07 €. Quant au trafic réseau public entrant, celui-ci est inclus dans tous les cas et pour toutes les régions.", + "pci_instances_regions_change_model_message": "Changer de modèle" +} diff --git a/packages/manager/apps/pci-instances/public/translations/stepper/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/stepper/Messages_fr_FR.json index 0bde24eaa20d..ee40f96dcd7e 100644 --- a/packages/manager/apps/pci-instances/public/translations/stepper/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/stepper/Messages_fr_FR.json @@ -1,4 +1,4 @@ { - "next_button_label": "Suivant", - "edit_step_label": "Modifier cette étape" + "pci_instances_stepper_next_button_label": "Suivant", + "pci_instances_stepper_edit_step_label": "Modifier cette étape" } From 92298b1e27df161a5ef30d23f1723d2567f4a74d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 11 Oct 2024 16:11:03 +0200 Subject: [PATCH 54/76] feat(pci-instances): add model & region step components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1898 Signed-off-by: Frédéric Vilcot --- .../src/pages/instances/Instances.page.tsx | 4 +- .../instances/create/CreateInstance.page.tsx | 14 +- .../create/steps/ModelsStep.component.tsx | 262 ------------------ .../steps/model/ModelStep.component.tsx | 174 ++++++++++++ .../steps/model/StepContent.component.tsx | 75 +++++ .../steps/model/StepTitle.component.tsx | 36 +++ .../steps/model/TabContent.component.tsx | 106 +++++++ .../AvailableMacroRegions.component.tsx | 31 +++ .../AvailableMicroRegions.component.tsx | 51 ++++ .../steps/region/RegionStep.component.tsx | 208 ++++++++++++++ .../steps/region/StepContent.component.tsx | 100 +++++++ .../steps/region/StepTitle.component.tsx | 53 ++++ .../steps/region/TabContent.component.tsx | 118 ++++++++ .../region/UnavailableRegions.component.tsx | 151 ++++++++++ 14 files changed, 1113 insertions(+), 270 deletions(-) delete mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepContent.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepTitle.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/TabContent.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/AvailableMacroRegions.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/AvailableMicroRegions.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepContent.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepTitle.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/TabContent.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/UnavailableRegions.component.tsx diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index f74688ddadb1..cd0aa34f045a 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -301,7 +301,7 @@ const Instances: FC = () => { {project && }
- {t('common:instances_title')} + {t('common:pci_instances_common_instances_title')}
@@ -324,7 +324,7 @@ const Instances: FC = () => { color={ODS_THEME_COLOR_INTENT.primary} className="mr-4" /> - {t('common:create_instance')} + {t('common:pci_instances_common_create_instance')}
diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx index 953a00c1d374..9661a4cf5387 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx @@ -15,16 +15,17 @@ import { TBreadcrumbProps, } from '@/components/breadcrumb/Breadcrumb.component'; import { useHidePreloader } from '@/hooks/hidePreloader/useHidePreloader'; -import { ModelsStep } from './steps/ModelsStep.component'; +import { ModelStep } from './steps/model/ModelStep.component'; +import { RegionStep } from './steps/region/RegionStep.component'; const CreateInstance: FC = () => { const project = useRouteLoaderData('root') as TProject; const backHref = useHref('..'); - const { t } = useTranslation(['create', 'common']); + const { t } = useTranslation('common'); const breadcrumbItems = useMemo( () => [ { - label: t('common:create_instance'), + label: t('pci_instances_common_create_instance'), }, ], [t], @@ -52,14 +53,15 @@ const CreateInstance: FC = () => { color={ODS_THEME_COLOR_INTENT.primary} slot="start" /> - {t('go_back')} + {t('pci_instances_common_go_back')}
- {t('common:create_instance')} + {t('pci_instances_common_create_instance')}
- + +
); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx deleted file mode 100644 index 0d25a997e8d6..000000000000 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/ModelsStep.component.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { StepComponent, TabsComponent } from '@ovh-ux/manager-react-components'; -import clsx from 'clsx'; -import { TFunction } from 'i18next'; -import { FC, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; -import { useShallow } from 'zustand/react/shallow'; -import { - ODS_THEME_COLOR_INTENT, - ODS_THEME_TYPOGRAPHY_SIZE, -} from '@ovhcloud/ods-common-theming'; -import { OsdsChip, OsdsText } from '@ovhcloud/ods-components/react'; -import { FlavorTile } from '@ovh-ux/manager-pci-common/src/components/flavor-selector/FlavorTile.component'; -import { - ODS_CHIP_SIZE, - ODS_TEXT_LEVEL, - ODS_TEXT_SIZE, -} from '@ovhcloud/ods-components'; -import { useCatalog } from '@/data/hooks/catalog/useCatalog'; -import { Spinner } from '@/components/spinner/Spinner.component'; -import { - TModelCategory, - TModelEntity, - TModelPricing, - TPriceInterval, - TStorage, -} from '@/types/catalog/entity.types'; -import { DeepReadonly } from '@/types/utils.type'; -import { useAppStore } from '@/store/hooks/useAppStore'; -import { TStep, TStepId } from '@/store/slices/stepper.slice'; - -const modelStepId: TStepId = 'model'; -const validatedModelStepState: Partial = { - isChecked: true, - isLocked: true, - isOpen: false, -}; -const editedModelStepState: Partial = { - isChecked: false, - isLocked: false, - isOpen: true, -}; - -type TTabProps = { - t: TFunction; - category: DeepReadonly; -}; - -type TTabContentProps = TTabProps & { - modelName: string | null; - setModelName: (newName: string) => void; - data?: TModelEntity; -}; - -type TTabTitleProps = TTabProps & { - isSelected?: boolean; -}; - -const TabContent: FC = ({ - t, - category, - data, - modelName, - setModelName, -}) => { - const getModelPrice = useCallback( - ( - pricings: DeepReadonly, - key: TPriceInterval, - ): number | undefined => - pricings.find((price) => price.interval === key)?.price, - [], - ); - - const handleModelTileClick = useCallback( - (name: string) => () => setModelName(name), - [setModelName], - ); - return ( -
- - {t(`model_category_${category.name}_description`)} - -
- {data?.models.data - .filter((model) => model.category === category.name) - .map( - ({ - name, - specifications, - compatibleLocalzone, - compatibleRegion, - isNew, - pricings, - }) => { - const monthlyPrice = getModelPrice(pricings, 'month'); - const hourlyPrice = getModelPrice(pricings, 'hour') ?? 0; - return ( -
- -
- ); - }, - )} -
-
- ); -}; - -const TabTitle: FC = ({ t, category, isSelected }) => ( - -
- - {t(`model_category_${category.name}_title`)} - - {category.isNew && ( - - - {t('new')} - - - )} -
-
-); - -export const ModelsStep: FC = () => { - const { projectId } = useParams() as { projectId: string }; - const { t } = useTranslation(['create', 'stepper']); - const { data, isLoading } = useCatalog<'modelSelector'>(projectId, { - selector: 'modelSelector', - }); - const { stepStateById, modelName, setModelName, updateStep } = useAppStore( - useShallow((state) => ({ - stepStateById: state.stepStateById(), - modelName: state.modelName(), - setModelName: state.setModelName, - updateStep: state.updateStep, - })), - ); - - const handleNextStep = useCallback( - (id: string) => { - updateStep(id as TStepId, validatedModelStepState); - }, - [updateStep], - ); - - const handleEditStep = useCallback( - (id: string) => { - updateStep(id as TStepId, editedModelStepState); - }, - [updateStep], - ); - - const modelStepState = useMemo(() => stepStateById(modelStepId), [ - stepStateById, - ]); - - const tabTitle = useCallback( - (category: DeepReadonly, isSelected?: boolean) => ( - - ), - [t], - ); - - const tabElement = useCallback( - (category: DeepReadonly) => ( - - ), - [data, modelName, setModelName, t], - ); - - return ( -
- - <> - {isLoading && } - {data && ( - > - items={data.models.categories as TModelCategory[]} - itemKey={({ name }) => name} - titleElement={tabTitle} - contentElement={tabElement} - /> - )} - - -
- ); -}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx new file mode 100644 index 000000000000..f4b8f2fce172 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx @@ -0,0 +1,174 @@ +import { + StepComponent, + useCatalogPrice, + useNotifications, +} from '@ovh-ux/manager-react-components'; +import { FC, useCallback, useEffect, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { useShallow } from 'zustand/react/shallow'; +import { OsdsLink } from '@ovhcloud/ods-components/react'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { useCatalog } from '@/data/hooks/catalog/useCatalog'; +import { TModelPricing, TPriceInterval } from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; +import { useAppStore } from '@/store/hooks/useAppStore'; +import { TStep, TStepId } from '@/store/slices/stepper.slice'; +import { StepTitle } from './StepTitle.component'; +import { StepContent } from './StepContent.component'; + +const modelStepId: TStepId = 'model'; +const validatedModelStepState: Partial = { + isChecked: true, + isLocked: true, + isOpen: false, +}; +const editedModelStepState: Partial = { + isChecked: false, + isLocked: false, + isOpen: true, +}; +const regionStepOpen: Partial = { + isOpen: true, + isLocked: false, + isChecked: false, +}; + +const getModelPriceByInterval = ( + pricings: DeepReadonly, + key: TPriceInterval, +): number | undefined => + pricings.find((price) => price.interval === key)?.price; + +export const ModelStep: FC = () => { + const { projectId } = useParams() as { projectId: string }; + const { t } = useTranslation(['models', 'stepper']); + const { clearNotifications, addError } = useNotifications(); + const { getTextPrice } = useCatalogPrice(4, { + exclVat: true, + }); + + const { data, isLoading, refetch, isError } = useCatalog<'modelSelector'>( + projectId, + { + selector: 'modelSelector', + }, + ); + + const { stepStateById, modelName, updateStep } = useAppStore( + useShallow((state) => ({ + stepStateById: state.stepStateById(), + modelName: state.modelName(), + updateStep: state.updateStep, + })), + ); + + const getSelectedModelMonthlyPrice = useCallback((): string | undefined => { + const selectedModelMonthlyPrice = getModelPriceByInterval( + data?.models.data.find((model) => model.name === modelName)?.pricings ?? + [], + 'month', + ); + return selectedModelMonthlyPrice + ? t('pci_instances_models_monthly_price_excl_vat', { + price: getTextPrice(selectedModelMonthlyPrice), + }) + : undefined; + }, [data?.models.data, getTextPrice, modelName, t]); + + const handleRefetch = useCallback(() => { + clearNotifications(); + refetch(); + }, [clearNotifications, refetch]); + + const errorMessage = useMemo( + () => ( + <> + + ), + }} + /> +
+ + + ), + [handleRefetch, t], + ); + + const modelStepState = useMemo(() => stepStateById(modelStepId), [ + stepStateById, + ]); + + const handleNextStep = useCallback( + (id: string) => { + updateStep(id as TStepId, validatedModelStepState); + updateStep('region', regionStepOpen); + }, + [updateStep], + ); + + const handleEditStep = useCallback( + (id: string) => { + updateStep(id as TStepId, editedModelStepState); + updateStep('region', { isOpen: false }); + }, + [updateStep], + ); + + const handleError = useCallback(() => { + if (isError) addError(errorMessage); + }, [isError, addError, errorMessage]); + + useEffect(() => { + handleError(); + return () => clearNotifications(); + }, [isError, addError, errorMessage, clearNotifications, handleError]); + + return ( +
+ + } + {...(!isLoading && + !isError && { + next: { + action: handleNextStep, + label: t('stepper:pci_instances_stepper_next_button_label'), + isDisabled: !modelName, + }, + })} + edit={{ + action: handleEditStep, + label: t('stepper:pci_instances_stepper_edit_step_label'), + isDisabled: false, + }} + > + + +
+ ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepContent.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepContent.component.tsx new file mode 100644 index 000000000000..5a5fb4cf41fe --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepContent.component.tsx @@ -0,0 +1,75 @@ +import { Notifications, TabsComponent } from '@ovh-ux/manager-react-components'; +import { OsdsDivider } from '@ovhcloud/ods-components/react'; +import { FC, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Spinner } from '@/components/spinner/Spinner.component'; +import { + TModelCategory, + TModelEntity, + TModelPricing, + TPriceInterval, +} from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; +import { TabTitle } from '@/components/tab/TabTitle.component'; +import { TabContent } from './TabContent.component'; + +type TStepContentProps = { + data?: TModelEntity; + isStepLoading: boolean; + isNotificationOpen: boolean; + getModelPriceByInterval: ( + pricings: DeepReadonly, + key: TPriceInterval, + ) => number | undefined; +}; + +export const StepContent: FC = ({ + data, + isStepLoading, + isNotificationOpen, + getModelPriceByInterval, +}) => { + const { t } = useTranslation('models'); + + const tabTitle = useCallback( + (category: DeepReadonly, isSelected?: boolean) => ( + + ), + [t], + ); + + const tabContent = useCallback( + (category: DeepReadonly) => ( + + ), + [data, getModelPriceByInterval], + ); + return ( + <> + {isStepLoading && } + {isNotificationOpen && ( + <> + + + + + )} + {data && ( + > + items={data.models.categories as TModelCategory[]} + itemKey={({ name }) => name} + titleElement={tabTitle} + contentElement={tabContent} + /> + )} + + ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepTitle.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepTitle.component.tsx new file mode 100644 index 000000000000..3856c0265b14 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepTitle.component.tsx @@ -0,0 +1,36 @@ +import { FC, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; +import { DeepReadonly } from '@/types/utils.type'; +import { useAppStore } from '@/store/hooks/useAppStore'; +import { TStep } from '@/store/slices/stepper.slice'; + +type TStepTitleProps = DeepReadonly<{ + modelStepState?: TStep; + modelMonthlyPrice?: string; +}>; + +export const StepTitle: FC = ({ + modelStepState, + modelMonthlyPrice, +}) => { + const { t } = useTranslation('models'); + + const { modelName } = useAppStore( + useShallow((state) => ({ + modelName: state.modelName(), + })), + ); + + const modelStepTitle = useMemo( + () => + modelName && !modelStepState?.isOpen + ? `${t('pci_instances_models_chosen_model_message', { + model: modelName.toUpperCase(), + })} ${modelMonthlyPrice || ''}` + : t('pci_instances_models_select_template'), + [modelName, modelStepState?.isOpen, t, modelMonthlyPrice], + ); + + return modelStepTitle; +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/TabContent.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/TabContent.component.tsx new file mode 100644 index 000000000000..4d8a0817e4eb --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/TabContent.component.tsx @@ -0,0 +1,106 @@ +import { useShallow } from 'zustand/react/shallow'; +import { FlavorTile } from '@ovh-ux/manager-pci-common/src/components/flavor-selector/FlavorTile.component'; +import { FC, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TabContentWrapper } from '@/components/tab/TabContentWrapper.component'; +import { useAppStore } from '@/store/hooks/useAppStore'; +import { + TModel, + TModelCategory, + TModelEntity, + TModelPricing, + TPriceInterval, + TStorage, +} from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; + +type TTabContentProps = { + category: DeepReadonly; + data?: TModelEntity; + getModelPriceByInterval: ( + pricings: DeepReadonly, + key: TPriceInterval, + ) => number | undefined; +}; + +export const TabContent: FC = ({ + category, + data, + getModelPriceByInterval, +}) => { + const { t } = useTranslation('models'); + const { modelName, setModelName } = useAppStore( + useShallow((state) => ({ + modelName: state.modelName(), + setModelName: state.setModelName, + })), + ); + + const filterModels = useCallback( + (model: DeepReadonly) => model.category === category.name, + [category.name], + ); + + const handleModelTileClick = useCallback( + (name: string) => () => setModelName(name), + [setModelName], + ); + + return ( + +
+ {data?.models.data + .filter(filterModels) + .map( + ({ + name, + specifications, + compatibleLocalzone, + compatibleRegion, + isNew, + pricings, + }) => { + const monthlyPrice = getModelPriceByInterval(pricings, 'month'); + const hourlyPrice = + getModelPriceByInterval(pricings, 'hour') ?? 0; + return ( +
+ +
+ ); + }, + )} +
+
+ ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/AvailableMacroRegions.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/AvailableMacroRegions.component.tsx new file mode 100644 index 000000000000..4d83498373f4 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/AvailableMacroRegions.component.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react'; +import { TRegion } from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; +import { RegionTile } from '@/components/tile/RegionTile.component'; + +export type TAvailableMacroRegionsProps = DeepReadonly<{ + availableMacroRegions: TRegion[]; + getRegionLabel: (name: string, datacenter: string) => string; + onRegionTileClick: ({ name, datacenter }: TRegion) => () => void; + selectedRegionDatacenter?: string; +}>; + +export const AvailableMacroRegions: FC = ({ + availableMacroRegions, + selectedRegionDatacenter, + getRegionLabel, + onRegionTileClick, +}) => ( +
+ {availableMacroRegions.map((macroRegion) => ( +
+ +
+ ))} +
+); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/AvailableMicroRegions.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/AvailableMicroRegions.component.tsx new file mode 100644 index 000000000000..391b1e68e6ea --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/AvailableMicroRegions.component.tsx @@ -0,0 +1,51 @@ +import { FC, useCallback } from 'react'; +import { OsdsText } from '@ovhcloud/ods-components/react'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { ODS_TEXT_LEVEL, ODS_TEXT_SIZE } from '@ovhcloud/ods-components'; +import { useTranslation } from 'react-i18next'; +import { TRegion } from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; +import { RegionTile } from '../../../../../components/tile/RegionTile.component'; +import { TRegionItem } from '@/store/slices/form.slice'; + +export type TAvailableMicroRegionsProps = DeepReadonly<{ + availableMicroRegions: TRegion[]; + onRegionTileClick: ({ name, datacenter }: TRegion) => () => void; + selectedRegion: TRegionItem | null; +}>; + +export const AvailableMicroRegions: FC = ({ + availableMicroRegions, + onRegionTileClick, + selectedRegion, +}) => { + const { t } = useTranslation('regions'); + + const filterMicroRegions = useCallback( + (region: TRegion) => region.datacenter === selectedRegion?.datacenter, + [selectedRegion], + ); + + return ( +
+ + {t('pci_instances_regions_location')} + +
+ {availableMicroRegions.filter(filterMicroRegions).map((microRegion) => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx new file mode 100644 index 000000000000..21370577681c --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx @@ -0,0 +1,208 @@ +import { FC, useCallback, useEffect, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { useShallow } from 'zustand/react/shallow'; +import { + StepComponent, + useNotifications, +} from '@ovh-ux/manager-react-components'; +import { useTranslation } from 'react-i18next'; +import { + updateCatalogQueryData, + useCatalog, +} from '@/data/hooks/catalog/useCatalog'; +import { useAppStore } from '@/store/hooks/useAppStore'; +import { TStep, TStepId } from '@/store/slices/stepper.slice'; +import { useActivateRegion } from '@/data/hooks/region/useActivateRegion'; +import queryClient from '@/queryClient'; +import { StepTitle } from './StepTitle.component'; +import { StepContent } from './StepContent.component'; + +type TgetRegionActivatedStatus = 'loading' | 'success' | 'error'; + +const regionStepId: TStepId = 'region'; +const validatedRegionStepState: Partial = { + isChecked: true, + isLocked: true, + isOpen: false, +}; +const editedRegionStepState: Partial = { + isChecked: false, + isLocked: false, + isOpen: true, +}; + +export const RegionStep: FC = () => { + const { t } = useTranslation(['regions', 'stepper']); + const { projectId } = useParams() as { projectId: string }; + const { + clearNotifications, + addInfo, + addSuccess, + addError, + notifications, + } = useNotifications(); + + const { + modelName, + stepStateById, + selectedRegion, + setRegion, + updateStep, + } = useAppStore( + useShallow((state) => ({ + modelName: state.modelName(), + stepStateById: state.stepStateById(), + updateStep: state.updateStep, + selectedRegion: state.region(), + setRegion: state.setRegion, + })), + ); + const { data } = useCatalog<'regionSelector'>(projectId, { + selector: 'regionSelector', + enabled: modelName !== null, + }); + + const getRegionActivationdMessage = useCallback( + (status: TgetRegionActivatedStatus): string => + t(`pci_instances_regions_activation_${status}_message`, { + region: selectedRegion?.name, + }), + [t, selectedRegion], + ); + + const handleRegionActivationSuccess = useCallback( + (regionName: string) => { + clearNotifications(); + updateCatalogQueryData(queryClient, projectId, regionName); + addSuccess(getRegionActivationdMessage('success')); + updateStep(regionStepId, validatedRegionStepState); + }, + [ + addSuccess, + clearNotifications, + getRegionActivationdMessage, + projectId, + updateStep, + ], + ); + + const handleRegionActivationError = useCallback(() => { + clearNotifications(); + addError(getRegionActivationdMessage('error'), true); + }, [addError, clearNotifications, getRegionActivationdMessage]); + + const { activateRegion, isPending, isSuccess } = useActivateRegion( + projectId, + { + onError: handleRegionActivationError, + onSuccess: handleRegionActivationSuccess, + }, + ); + + const handleRegionActivationLoading = useCallback(() => { + if (isPending) addInfo(getRegionActivationdMessage('loading')); + }, [addInfo, getRegionActivationdMessage, isPending]); + + const regionStepState = useMemo(() => stepStateById(regionStepId), [ + stepStateById, + ]); + + const isSelectedRegionActivated = useMemo( + (): boolean => + !!selectedRegion && + !!data?.regions.data.allAvailableRegions.find( + (availableRegion) => availableRegion.name === selectedRegion.name, + )?.isActivated, + [data?.regions.data.allAvailableRegions, selectedRegion], + ); + + const handleSelectedRegion = useCallback(() => { + if (data && selectedRegion) { + const { allAvailableRegions } = data.regions.data; + const isSelectedRegionAvailable = allAvailableRegions.some( + (availableRegion) => availableRegion.name === selectedRegion.name, + ); + if (!isSelectedRegionAvailable) { + clearNotifications(); + setRegion(null); + } + } + }, [clearNotifications, data, selectedRegion, setRegion]); + + const handleNextStep = useCallback(() => { + clearNotifications(); + if (selectedRegion) { + if (!isSelectedRegionActivated) { + activateRegion(selectedRegion.name); + } else { + updateStep(regionStepId, validatedRegionStepState); + } + } + }, [ + activateRegion, + clearNotifications, + isSelectedRegionActivated, + selectedRegion, + updateStep, + ]); + + const handleEditStep = useCallback( + (id: string) => { + clearNotifications(); + updateStep(id as TStepId, editedRegionStepState); + }, + [clearNotifications, updateStep], + ); + + useEffect(() => { + handleSelectedRegion(); + }, [data, handleSelectedRegion, selectedRegion, setRegion]); + + useEffect(() => { + handleRegionActivationLoading(); + }, [ + clearNotifications, + isPending, + addInfo, + getRegionActivationdMessage, + handleRegionActivationLoading, + ]); + + return ( +
+ + } + {...(!isPending && { + next: { + action: handleNextStep, + label: t('stepper:pci_instances_stepper_next_button_label'), + isDisabled: !selectedRegion, + }, + })} + {...(regionStepState?.isChecked && { + edit: { + action: handleEditStep, + label: t('stepper:pci_instances_stepper_edit_step_label'), + isDisabled: false, + }, + })} + > + + +
+ ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepContent.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepContent.component.tsx new file mode 100644 index 000000000000..82d23eb6ff61 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepContent.component.tsx @@ -0,0 +1,100 @@ +import { Notifications, TabsComponent } from '@ovh-ux/manager-react-components'; +import { OsdsDivider, OsdsText } from '@ovhcloud/ods-components/react'; +import { FC, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ODS_TEXT_LEVEL, ODS_TEXT_SIZE } from '@ovhcloud/ods-components'; +import { useShallow } from 'zustand/react/shallow'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { TRegionCategory, TRegionEntity } from '@/types/catalog/entity.types'; +import { DeepReadonly } from '@/types/utils.type'; +import { TabTitle } from '../../../../../components/tab/TabTitle.component'; +import { TabContent } from './TabContent.component'; +import { Spinner } from '@/components/spinner/Spinner.component'; +import { useAppStore } from '@/store/hooks/useAppStore'; + +type TStepContentProps = { + data?: TRegionEntity; + isSelectedRegionActivated: boolean; + isActivateRegionMutationLoading: boolean; +}; + +export const StepContent: FC = ({ + data, + isSelectedRegionActivated, + isActivateRegionMutationLoading, +}) => { + const { t } = useTranslation('regions'); + const { selectedRegion } = useAppStore( + useShallow((state) => ({ + selectedRegion: state.region(), + })), + ); + + const regionHasExtraCoast = useMemo((): boolean => { + const selectedRegionCategory = data?.regions.data.allAvailableRegions.find( + (region) => region.name === selectedRegion?.name, + )?.category; + return ( + selectedRegionCategory === 'oceania' || + selectedRegionCategory === 'south_east_asia' + ); + }, [data?.regions.data.allAvailableRegions, selectedRegion?.name]); + + const tabTitle = useCallback( + (category: DeepReadonly, isSelected?: boolean) => ( + + ), + [t], + ); + + const tabContent = useCallback( + (category: DeepReadonly) => ( + + ), + [data], + ); + + return ( + <> + + + + {isActivateRegionMutationLoading && } + {data && !isActivateRegionMutationLoading && ( + <> + > + items={data.regions.categories as TRegionCategory[]} + itemKey={({ name }) => name} + titleElement={tabTitle} + contentElement={tabContent} + /> + {selectedRegion && !isSelectedRegionActivated && ( +
+ + {t('pci_instances_regions_not_activated_message')} + +
+ )} + {regionHasExtraCoast && ( +
+ + {t('pci_instances_regions_extra_coast_message')} + +
+ )} + + )} + + ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepTitle.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepTitle.component.tsx new file mode 100644 index 000000000000..50e72fe4a2f5 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepTitle.component.tsx @@ -0,0 +1,53 @@ +import { FC, useMemo } from 'react'; +import { OsdsDivider } from '@ovhcloud/ods-components/react'; +import { + Notifications, + useTranslatedMicroRegions, +} from '@ovh-ux/manager-react-components'; +import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; +import { DeepReadonly } from '@/types/utils.type'; +import { useAppStore } from '@/store/hooks/useAppStore'; +import { TStep } from '@/store/slices/stepper.slice'; + +type TStepTitleProps = DeepReadonly<{ + isNotificationOpen: boolean; + regionStepState?: TStep; +}>; + +export const StepTitle: FC = ({ + isNotificationOpen, + regionStepState, +}) => { + const { t } = useTranslation('regions'); + const { translateMacroRegion } = useTranslatedMicroRegions(); + + const { selectedRegion } = useAppStore( + useShallow((state) => ({ + selectedRegion: state.region(), + })), + ); + + const regionStepTitle = useMemo( + () => + selectedRegion && !regionStepState?.isOpen + ? t('pci_instances_regions_chosen_region_message', { + region: translateMacroRegion(selectedRegion.name), + }) + : t('pci_instances_regions_select_localization'), + [regionStepState?.isOpen, selectedRegion, t, translateMacroRegion], + ); + + return ( + <> + {regionStepTitle} + {isNotificationOpen && ( + <> + + + + + )} + + ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/TabContent.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/TabContent.component.tsx new file mode 100644 index 000000000000..a2dc8e51ba91 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/TabContent.component.tsx @@ -0,0 +1,118 @@ +import { useTranslatedMicroRegions } from '@ovh-ux/manager-react-components'; +import { FC, useCallback, useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { DeepReadonly } from '@/types/utils.type'; +import { + TRegion, + TRegionCategory, + TRegionEntity, +} from '@/types/catalog/entity.types'; +import { TabContentWrapper } from '../../../../../components/tab/TabContentWrapper.component'; +import { AvailableMacroRegions } from './AvailableMacroRegions.component'; +import { AvailableMicroRegions } from './AvailableMicroRegions.component'; +import { UnavailableRegions } from './UnavailableRegions.component'; +import { useAppStore } from '@/store/hooks/useAppStore'; + +type TTabContentProps = { + category: DeepReadonly; + data: TRegionEntity; +}; + +export const TabContent: FC = ({ category, data }) => { + const { + translateMacroRegion, + translateMicroRegion, + } = useTranslatedMicroRegions(); + const { + allAvailableRegions, + availableMacroRegions, + availableMicroRegions, + unavailableRegions, + } = data.regions.data; + + const { selectedRegion, setRegion } = useAppStore( + useShallow((state) => ({ + selectedRegion: state.region(), + setRegion: state.setRegion, + })), + ); + + const groupRegionsByCategory = useCallback( + (regions: DeepReadonly): DeepReadonly => + category.name === 'all' + ? regions + : regions.filter((region) => region.category === category.name), + [category.name], + ); + + const allAvailableRegionsByCategory = useMemo( + () => groupRegionsByCategory(allAvailableRegions), + [groupRegionsByCategory, allAvailableRegions], + ); + + const availableMacroRegionsByCategory = useMemo( + () => groupRegionsByCategory(availableMacroRegions), + [groupRegionsByCategory, availableMacroRegions], + ); + const availableMicroRegionsByCategory = useMemo( + () => groupRegionsByCategory(availableMicroRegions), + [groupRegionsByCategory, availableMicroRegions], + ); + const unavailableRegionsByCategory = useMemo( + () => groupRegionsByCategory(unavailableRegions), + [groupRegionsByCategory, unavailableRegions], + ); + + const selectedRegionIsMicroRegion = useMemo( + () => + selectedRegion && + availableMicroRegionsByCategory.find( + (elt) => elt.name === selectedRegion.name, + ), + [availableMicroRegionsByCategory, selectedRegion], + ); + + const handleRegionTileClick = useCallback( + ({ name, datacenter }: TRegion) => () => { + setRegion({ name, datacenter }); + }, + [setRegion], + ); + + const getRegionLabel = useCallback( + (name: string, datacenter: string) => { + const regionMicrolabel = translateMicroRegion(name); + const regionMacrolabel = translateMacroRegion(datacenter); + if (availableMicroRegions.some((elt) => elt.name === name)) { + return regionMacrolabel; + } + return regionMicrolabel; + }, + [availableMicroRegions, translateMacroRegion, translateMicroRegion], + ); + + return ( + + + {selectedRegionIsMicroRegion && ( + + )} + {unavailableRegionsByCategory.length > 0 && ( + + )} + + ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/UnavailableRegions.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/UnavailableRegions.component.tsx new file mode 100644 index 000000000000..9c794ebefa32 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/UnavailableRegions.component.tsx @@ -0,0 +1,151 @@ +import { + ODS_CHECKBOX_BUTTON_SIZE, + ODS_ICON_NAME, + ODS_ICON_SIZE, + ODS_TEXT_LEVEL, + ODS_TEXT_SIZE, +} from '@ovhcloud/ods-components'; +import { + OsdsCheckbox, + OsdsCheckboxButton, + OsdsIcon, + OsdsLink, + OsdsText, +} from '@ovhcloud/ods-components/react'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { FC, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; +import { DeepReadonly } from '@/types/utils.type'; +import { RegionTile } from '../../../../../components/tile/RegionTile.component'; +import { TRegion } from '@/types/catalog/entity.types'; +import { useAppStore } from '@/store/hooks/useAppStore'; + +type TUnavailableRegionsCheckBoxProps = DeepReadonly<{ + label: string; + className: string; + checked: boolean; + onCheckChange: (event: CustomEvent) => void; +}>; + +const UnavailableRegionsCheckBox: FC = ({ + label, + className, + checked, + onCheckChange, +}) => { + return ( +
+ + + + {label} + + + +
+ ); +}; + +export type TUnavailableRegionsProps = DeepReadonly<{ + unavailableRegions: TRegion[]; + availableRegions: TRegion[]; + getRegionLabel: (name: string, datacenter: string) => string; +}>; + +export const UnavailableRegions: FC = ({ + unavailableRegions, + availableRegions, + getRegionLabel, +}) => { + const [checked, setChecked] = useState(false); + const { t } = useTranslation('regions'); + + const { updateStep } = useAppStore( + useShallow((state) => ({ + updateStep: state.updateStep, + })), + ); + + const handleCheckChange = useCallback( + (event: CustomEvent) => setChecked(event.detail.checked), + [], + ); + + const handleChangeModelClick = useCallback(() => { + updateStep('region', { + isOpen: false, + isLocked: true, + }); + updateStep('model', { isOpen: true, isLocked: false, isChecked: false }); + }, [updateStep]); + + return ( + <> + + {checked && ( + <> +
+ {unavailableRegions.map((unavailableRegion) => ( +
+ +
+ ))} +
+
+ + {t( + `pci_instances_regions_other_models_${ + unavailableRegions.length === 1 ? 'region' : 'regions' + }_availability_message`, + )} + + + {t('pci_instances_regions_change_model_message')} + + +
+ + )} + + ); +}; From 0c0dd18823c2a2161e9aacaeb6040a88b8b992a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 11 Oct 2024 16:11:46 +0200 Subject: [PATCH 55/76] feat(pci-instances): update appStore slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1898 Signed-off-by: Frédéric Vilcot --- .../src/data/hooks/region/useActivateRegion.ts | 7 +++++++ .../pci-instances/src/store/slices/form.slice.ts | 14 ++++++++++++++ .../src/store/slices/stepper.slice.ts | 7 +++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.ts b/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.ts index a4bfdd849a18..93f2969d98c8 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/region/useActivateRegion.ts @@ -3,6 +3,7 @@ import { useCallback } from 'react'; import { DeepReadonly } from '@/types/utils.type'; import { activateRegion } from '@/data/api/region'; import { TActivatedRegionDto } from '@/types/region/api.types'; +import { instancesQueryKey } from '@/utils'; export type TUseActivateRegionCallbacks = DeepReadonly<{ onSuccess?: (region: string) => void; @@ -13,6 +14,11 @@ export const useActivateRegion = ( projectId: string, { onError, onSuccess }: TUseActivateRegionCallbacks = {}, ) => { + const mutationKey = instancesQueryKey(projectId, [ + 'creation', + 'region', + 'activate', + ]); const mutationFn = useCallback( (region: string) => activateRegion(projectId, region), [projectId], @@ -26,6 +32,7 @@ export const useActivateRegion = ( ); const mutation = useMutation({ + mutationKey, mutationFn, onError, onSuccess: handleSuccess, diff --git a/packages/manager/apps/pci-instances/src/store/slices/form.slice.ts b/packages/manager/apps/pci-instances/src/store/slices/form.slice.ts index b2da0aab74bd..f4fe62843047 100644 --- a/packages/manager/apps/pci-instances/src/store/slices/form.slice.ts +++ b/packages/manager/apps/pci-instances/src/store/slices/form.slice.ts @@ -1,7 +1,13 @@ import { StateCreator } from 'zustand'; +export type TRegionItem = { + name: string; + datacenter: string; +}; + export type TForm = { modelName: string | null; + region: TRegionItem | null; }; export type TState = { @@ -11,17 +17,20 @@ export type TState = { // Computed export type TQuery = { modelName: () => string | null; + region: () => TRegionItem | null; }; // Handlers export type TCommand = { setModelName: (newName: string) => void; + setRegion: (newRegion: TRegionItem | null) => void; }; export type TFormSlice = TState & TCommand & TQuery; const intialForm: TForm = { modelName: null, + region: null, }; export const createFormSlice: StateCreator = ( @@ -33,5 +42,10 @@ export const createFormSlice: StateCreator = ( set((state) => ({ form: { ...state.form, modelName: newName }, })), + setRegion: (newRegion) => + set((state) => ({ + form: { ...state.form, region: newRegion }, + })), modelName: () => get().form.modelName, + region: () => get().form.region, }); diff --git a/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts b/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts index e472adcd3044..31217989171e 100644 --- a/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts +++ b/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts @@ -1,6 +1,6 @@ import { StateCreator } from 'zustand'; -export type TStepId = 'model'; +export type TStepId = 'model' | 'region'; export type TStep = { isOpen: boolean; @@ -35,7 +35,10 @@ const initStep = (isOpen: boolean): TStep => ({ isLocked: false, }); -const initialSteps = new Map([['model', initStep(true)]]); +const initialSteps = new Map([ + ['model', initStep(true)], + ['region', initStep(false)], +]); export const createStepperSlice: StateCreator< TStepperSlice, From 705a15f51e6847b4c38af9d82019c969f6ae9de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 11 Oct 2024 16:12:19 +0200 Subject: [PATCH 56/76] test(pci-instances): update appStore test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1898 Signed-off-by: Frédéric Vilcot --- .../steps/region/RegionStep.component.tsx | 2 +- .../steps/region/StepContent.component.tsx | 8 ++--- .../steps/region/TabContent.component.tsx | 22 ++++++------- .../src/store/hooks/useAppStore.spec.ts | 32 +++++++++++++++---- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx index 21370577681c..dbc473663b6a 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx @@ -200,7 +200,7 @@ export const RegionStep: FC = () => {
diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepContent.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepContent.component.tsx index 82d23eb6ff61..df2194c39f68 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepContent.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepContent.component.tsx @@ -15,13 +15,13 @@ import { useAppStore } from '@/store/hooks/useAppStore'; type TStepContentProps = { data?: TRegionEntity; isSelectedRegionActivated: boolean; - isActivateRegionMutationLoading: boolean; + isPending: boolean; }; export const StepContent: FC = ({ data, isSelectedRegionActivated, - isActivateRegionMutationLoading, + isPending, }) => { const { t } = useTranslation('regions'); const { selectedRegion } = useAppStore( @@ -62,8 +62,8 @@ export const StepContent: FC = ({ - {isActivateRegionMutationLoading && } - {data && !isActivateRegionMutationLoading && ( + {isPending && } + {data && !isPending && ( <> > items={data.regions.categories as TRegionCategory[]} diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/TabContent.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/TabContent.component.tsx index a2dc8e51ba91..576d43f6b6f7 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/TabContent.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/TabContent.component.tsx @@ -45,22 +45,20 @@ export const TabContent: FC = ({ category, data }) => { [category.name], ); - const allAvailableRegionsByCategory = useMemo( - () => groupRegionsByCategory(allAvailableRegions), - [groupRegionsByCategory, allAvailableRegions], + const allAvailableRegionsByCategory = groupRegionsByCategory( + allAvailableRegions, ); - const availableMacroRegionsByCategory = useMemo( - () => groupRegionsByCategory(availableMacroRegions), - [groupRegionsByCategory, availableMacroRegions], + const availableMacroRegionsByCategory = groupRegionsByCategory( + availableMacroRegions, ); - const availableMicroRegionsByCategory = useMemo( - () => groupRegionsByCategory(availableMicroRegions), - [groupRegionsByCategory, availableMicroRegions], + + const availableMicroRegionsByCategory = groupRegionsByCategory( + availableMicroRegions, ); - const unavailableRegionsByCategory = useMemo( - () => groupRegionsByCategory(unavailableRegions), - [groupRegionsByCategory, unavailableRegions], + + const unavailableRegionsByCategory = groupRegionsByCategory( + unavailableRegions, ); const selectedRegionIsMicroRegion = useMemo( diff --git a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts index 5e507ed291ca..ce90b39b4a46 100644 --- a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts +++ b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts @@ -2,6 +2,7 @@ import { describe, test } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useAppStore } from './useAppStore'; import { TStep, TStepId } from '../slices/stepper.slice'; +import { TRegionItem } from '../slices/form.slice'; describe('Considering the useAppStore hook', () => { describe("Considering the 'FormSlice'", () => { @@ -9,25 +10,34 @@ describe('Considering the useAppStore hook', () => { type Data = { modelName: string; expectedModelName: string; + region: TRegionItem; + expectedRegion: TRegionItem; }; const fakeModelName1 = 'b3-8'; const fakeModelName2 = 'b2-7'; + const fakeRegion1: TRegionItem = { + name: 'GRA7', + datacenter: 'GRA', + }; + const expectedModelName1 = fakeModelName1; const expectedModelName2 = fakeModelName2; + const expectedRegion1 = fakeRegion1; + describe.each` - modelName | expectedModelName - ${fakeModelName1} | ${expectedModelName1} - ${fakeModelName2} | ${expectedModelName2} + modelName | expectedModelName | region | expectedRegion + ${fakeModelName1} | ${expectedModelName1} | ${fakeRegion1} | ${expectedRegion1} + ${fakeModelName2} | ${expectedModelName2} | ${null} | ${null} `( - 'Given a modelName <$modelName>', - ({ modelName, expectedModelName }: Data) => { + 'Given a modelName <$modelName> and a region <$region>', + ({ modelName, expectedModelName, region, expectedRegion }: Data) => { describe(`When invoking useAppStore hook,`, () => { + const { result } = renderHook(() => useAppStore()); test(`Then, expect model name to be ${JSON.stringify( expectedModelName, )}`, () => { - const { result } = renderHook(() => useAppStore()); expect(result.current).toHaveProperty('form'); expect(result.current.modelName()).toBeNull(); act(() => { @@ -35,6 +45,16 @@ describe('Considering the useAppStore hook', () => { }); expect(result.current.modelName()).toStrictEqual(expectedModelName); }); + + test(`Then, expect region to be ${JSON.stringify( + expectedRegion, + )}`, () => { + expect(result.current.region()).toBeNull(); + act(() => { + result.current.setRegion(region); + }); + expect(result.current.region()).toStrictEqual(expectedRegion); + }); }); }, ); From 6f4fa983d5923eff9aa4a4bf5d2aa7495d8f2d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Mon, 14 Oct 2024 12:10:42 +0200 Subject: [PATCH 57/76] test(pci-instances): add test suites for components & hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1909 Signed-off-by: Frédéric Vilcot --- .../apps/pci-instances/setup.vitest.ts | 6 ++ .../components/spinner/Spinner.component.tsx | 2 +- .../src/components/spinner/Spinner.spec.tsx | 12 ++++ .../statusChip/StatusChip.component.tsx | 6 +- .../components/statusChip/StatusChip.spec.tsx | 50 ++++++++++++++++ .../tab/TabContentWrapper.component.tsx | 2 +- .../components/tab/TabContentWrapper.spec.tsx | 44 ++++++++++++++ .../src/components/tab/TabTitle.spec.tsx | 36 ++++++++++++ .../hidePreloader/useHidePreloader.spec.tsx | 33 +++++++++++ .../useShellRoutingSync.spec.tsx | 57 +++++++++++++++++++ 10 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/components/spinner/Spinner.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/tab/TabTitle.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/hooks/hidePreloader/useHidePreloader.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/hooks/shellRoutingSync/useShellRoutingSync.spec.tsx diff --git a/packages/manager/apps/pci-instances/setup.vitest.ts b/packages/manager/apps/pci-instances/setup.vitest.ts index f788f7050d2a..4caefe6f78b0 100644 --- a/packages/manager/apps/pci-instances/setup.vitest.ts +++ b/packages/manager/apps/pci-instances/setup.vitest.ts @@ -2,3 +2,9 @@ import '@testing-library/jest-dom'; import { vi } from 'vitest'; vi.mock('zustand'); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (translationKey: string) => translationKey, + }), +})); diff --git a/packages/manager/apps/pci-instances/src/components/spinner/Spinner.component.tsx b/packages/manager/apps/pci-instances/src/components/spinner/Spinner.component.tsx index 44536617d444..61365d8346c5 100644 --- a/packages/manager/apps/pci-instances/src/components/spinner/Spinner.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/spinner/Spinner.component.tsx @@ -3,7 +3,7 @@ import { OsdsSpinner } from '@ovhcloud/ods-components/react'; import { FC } from 'react'; export const Spinner: FC = () => ( -
+
); diff --git a/packages/manager/apps/pci-instances/src/components/spinner/Spinner.spec.tsx b/packages/manager/apps/pci-instances/src/components/spinner/Spinner.spec.tsx new file mode 100644 index 000000000000..659a69cd2d19 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/spinner/Spinner.spec.tsx @@ -0,0 +1,12 @@ +import { describe } from 'vitest'; +import { render } from '@testing-library/react'; +import { ODS_SPINNER_SIZE } from '@ovhcloud/ods-components'; +import { Spinner } from './Spinner.component'; + +describe('Spinner component', () => { + test('Should render spinner with medium size', () => { + const { getByTestId } = render(); + const spinnerEl = getByTestId('spinner'); + expect(spinnerEl).toHaveStyle(`width: ${ODS_SPINNER_SIZE.md}`); + }); +}); diff --git a/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx index 22b89bf836ba..108e206c871e 100644 --- a/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.component.tsx @@ -14,7 +14,11 @@ const StatusChip = ({ status }: { status: TInstanceStatus }) => { const { t } = useTranslation('status'); return ( - + {t(status.state.toLowerCase())} ); diff --git a/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.spec.tsx b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.spec.tsx new file mode 100644 index 000000000000..ad7fa89af40e --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/statusChip/StatusChip.spec.tsx @@ -0,0 +1,50 @@ +import { render } from '@testing-library/react'; +import { describe } from 'vitest'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import StatusChip from './StatusChip.component'; +import { TInstanceStatus } from '@/data/hooks/instance/useInstances'; + +type Data = { + status: TInstanceStatus; + label: string; + bgColor: ODS_THEME_COLOR_INTENT; +}; + +const successStatus: TInstanceStatus = { + severity: 'success', + state: 'ACTIVE', +}; + +const warningStatus: TInstanceStatus = { + severity: 'warning', + state: 'PAUSED', +}; + +const errorStatus: TInstanceStatus = { + severity: 'error', + state: 'ERROR', +}; + +const infoStatus: TInstanceStatus = { + severity: 'info', + state: 'UNKNOWN', +}; + +describe('StatusChip component', () => { + test.each` + status | bgColor + ${successStatus} | ${ODS_THEME_COLOR_INTENT.success} + ${errorStatus} | ${ODS_THEME_COLOR_INTENT.error} + ${warningStatus} | ${ODS_THEME_COLOR_INTENT.warning} + ${infoStatus} | ${ODS_THEME_COLOR_INTENT.info} + `( + 'Should render the correct chip color <$bgColor> and label based on the status <$status>', + ({ status, bgColor }: Data) => { + const { getByText, getByTestId } = render(); + const chipEl = getByTestId('status-chip'); + expect(chipEl).toHaveStyle(`background-color: ${bgColor}`); + const labelEl = getByText(status.state.toLowerCase()); + expect(labelEl).toBeInTheDocument(); + }, + ); +}); diff --git a/packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.component.tsx b/packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.component.tsx index 749b044f64b2..18c04edfe16a 100644 --- a/packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/tab/TabContentWrapper.component.tsx @@ -11,7 +11,7 @@ export type TTabContentWrapperProps = DeepReadonly<{ export const TabContentWrapper: FC> = ({ description, children }) => ( -
+
{description && ( + Foo +
+); + +describe('TabContentWrapper component', () => { + test.each` + description | children + ${undefined} | ${null} + ${'foo description'} | ${null} + ${'bar description'} | ${children1} + `( + 'Should render correctly the tab content wrapper with an optional description <$description> and its children', + ({ description, children }: Data) => { + render( + + {children} + , + ); + const tabContentWrapperElt = screen.getByTestId('tab-content-wrapper'); + expect(tabContentWrapperElt).toBeInTheDocument(); + expect(tabContentWrapperElt).toHaveClass('p-6 pt-8'); + if (description) { + const descriptionElt = screen.getByText(description); + expect(descriptionElt).toBeInTheDocument(); + } + if (children) { + const childrenElt = screen.getByTestId('foo-child'); + expect(childrenElt).toBeInTheDocument(); + } + }, + ); +}); diff --git a/packages/manager/apps/pci-instances/src/components/tab/TabTitle.spec.tsx b/packages/manager/apps/pci-instances/src/components/tab/TabTitle.spec.tsx new file mode 100644 index 000000000000..3f138d3e1e3b --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/tab/TabTitle.spec.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe } from 'vitest'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { TabTitle } from './TabTitle.component'; + +type Data = { + label: string; + isSelected: boolean; + isNew?: boolean; +}; + +describe('TabTitle component', () => { + test.each` + label | isSelected | isNew + ${'foo'} | ${false} | ${false} + ${'bar'} | ${true} | ${true} + ${'fooz'} | ${false} | ${undefined} + `( + 'Should render correctly the tab title with a label <$label>, isSelected option <$isSelected> and optional isNew <$isNew> props', + ({ label, isSelected, isNew }: Data) => { + render(); + const titleElt = screen.getByText(label); + expect(titleElt).toBeInTheDocument(); + expect(titleElt).toHaveClass('whitespace-nowrap text-lg'); + if (isSelected) { + expect(titleElt).toHaveClass('font-bold'); + expect(titleElt).toHaveStyle(`color: ${ODS_THEME_COLOR_INTENT.text}`); + } + expect(titleElt).toHaveStyle(`color: ${ODS_THEME_COLOR_INTENT.text}`); + if (isNew) { + const isNewElt = screen.getByText('pci_instances_common_new'); + expect(isNewElt).toBeInTheDocument(); + } + }, + ); +}); diff --git a/packages/manager/apps/pci-instances/src/hooks/hidePreloader/useHidePreloader.spec.tsx b/packages/manager/apps/pci-instances/src/hooks/hidePreloader/useHidePreloader.spec.tsx new file mode 100644 index 000000000000..5cec3621a37b --- /dev/null +++ b/packages/manager/apps/pci-instances/src/hooks/hidePreloader/useHidePreloader.spec.tsx @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; +import { + ShellContext, + ShellContextType, +} from '@ovh-ux/manager-react-shell-client'; +import { describe, vi } from 'vitest'; +import { useHidePreloader } from './useHidePreloader'; + +const shellContext = { + shell: { + ux: { + hidePreloader: vi.fn(), + }, + }, +}; + +describe('useHidePreloader hook', () => { + test('Should call the hidePreloader() function correctly', () => { + const { rerender } = renderHook(() => useHidePreloader(), { + wrapper: (props) => ( + + {props.children} + + ), + }); + + expect(shellContext.shell.ux.hidePreloader).toHaveBeenCalled(); + rerender(); + expect(shellContext.shell.ux.hidePreloader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/manager/apps/pci-instances/src/hooks/shellRoutingSync/useShellRoutingSync.spec.tsx b/packages/manager/apps/pci-instances/src/hooks/shellRoutingSync/useShellRoutingSync.spec.tsx new file mode 100644 index 000000000000..e3e27f513340 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/hooks/shellRoutingSync/useShellRoutingSync.spec.tsx @@ -0,0 +1,57 @@ +import { renderHook } from '@testing-library/react'; +import { + ShellContext, + ShellContextType, +} from '@ovh-ux/manager-react-shell-client'; +import { describe, vi } from 'vitest'; +import * as ReactRouterDom from 'react-router-dom'; +import { useShellRoutingSync } from './useShellRoutingSync'; + +const location1: ReactRouterDom.Location = { + pathname: '/pci/projects/12345/instances', + search: '', + hash: '', + state: '', + key: 'foo', +}; + +const location2: ReactRouterDom.Location = { + ...location1, + pathname: '/pci/projects/12345/instances/new', + key: 'bar', +}; + +const shellContext = { + shell: { + routing: { + onHashChange: vi.fn(), + stopListenForHashChange: vi.fn(), + }, + }, +}; + +const mockedUseLocation = vi.spyOn(ReactRouterDom, 'useLocation'); + +describe('useShellRoutingSync hook', () => { + test('Should call the stopListenForHashChange() & onHashChange() functions correctly', () => { + mockedUseLocation.mockReturnValueOnce(location1).mockReturnValue(location2); + + const { rerender } = renderHook(() => useShellRoutingSync(), { + wrapper: (props) => ( + + {props.children} + + ), + }); + + expect( + shellContext.shell.routing.stopListenForHashChange, + ).toHaveBeenCalledTimes(1); + expect(shellContext.shell.routing.onHashChange).toHaveBeenCalledTimes(1); + + rerender(); + expect(shellContext.shell.routing.onHashChange).toHaveBeenCalledTimes(2); + }); +}); From df3305dcc999b0d43a1dbab09f9a4017fb4b834e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 15 Oct 2024 10:19:43 +0200 Subject: [PATCH 58/76] fix(pci-instances): update broken onboarding translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1920 Signed-off-by: Frédéric Vilcot --- .../onboarding/Messages_fr_FR.json | 26 +++++++++---------- .../instances/onboarding/Onboarding.page.tsx | 20 +++++++------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json index 69934def5f00..f3f09829152b 100644 --- a/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/onboarding/Messages_fr_FR.json @@ -1,15 +1,15 @@ { - "not_created_message": "Vous n'avez pas encore créé d'instance.", - "content_message_1": "Déployez des instances parmi une large gamme de modèles et profitez de la flexibilité du cloud pour croître selon vos besoins.", - "content_message_2": "Dans notre catalogue d'instances, choisissez le modèle qui vous convient parmi la gamme Sandbox, dont les ressources sont partagées, la gamme Guaranteed Resources, aux performances constantes pour les applications exigeantes, la gamme GPU, idéale pour les calculs parallélisés, et la gamme IOPS, pour les bases de données ou le big data.", - "advice_message": "À tout moment, vous avez la possibilité d’augmenter la capacité de vos instances selon vos besoins. Vous pouvez aussi opter pour les instances de type flex, qui permettent également de réduire cette capacité.", - "guide_title": "Tutoriel", - "create_instance_title": "Créer une instance depuis l’espace client", - "post_install_script_title": "Lancer un script lors de la creation d’un instance", - "back_up_instance_title": "Sauvegarder une instance", - "instance_introduction_title": "Introduction aux instances et autres notions cloud", - "create_instance_description": "Ce guide vous montre comment créer une instance sur Public Cloud", - "post_install_script_description": "", - "back_up_instance_description": "Découvrez comment sauvegarder une instance Public Cloud en quelques clics", - "instance_introduction_description": "" + "pci_instances_onboarding_not_created_message": "Vous n'avez pas encore créé d'instance.", + "pci_instances_onboarding_content_message_1": "Déployez des instances parmi une large gamme de modèles et profitez de la flexibilité du cloud pour croître selon vos besoins.", + "pci_instances_onboarding_content_message_2": "Dans notre catalogue d'instances, choisissez le modèle qui vous convient parmi la gamme Sandbox, dont les ressources sont partagées, la gamme Guaranteed Resources, aux performances constantes pour les applications exigeantes, la gamme GPU, idéale pour les calculs parallélisés, et la gamme IOPS, pour les bases de données ou le big data.", + "pci_instances_onboarding_advice_message": "À tout moment, vous avez la possibilité d’augmenter la capacité de vos instances selon vos besoins. Vous pouvez aussi opter pour les instances de type flex, qui permettent également de réduire cette capacité.", + "pci_instances_onboarding_guide_title": "Tutoriel", + "pci_instances_onboarding_create_instance_title": "Créer une instance depuis l’espace client", + "pci_instances_onboarding_post_install_script_title": "Lancer un script lors de la creation d’un instance", + "pci_instances_onboarding_back_up_instance_title": "Sauvegarder une instance", + "pci_instances_onboarding_instance_introduction_title": "Introduction aux instances et autres notions cloud", + "pci_instances_onboarding_create_instance_description": "Ce guide vous montre comment créer une instance sur Public Cloud", + "pci_instances_onboarding_post_install_script_description": "", + "pci_instances_onboarding_back_up_instance_description": "Découvrez comment sauvegarder une instance Public Cloud en quelques clics", + "pci_instances_onboarding_instance_introduction_description": "" } diff --git a/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx index 4d1dcf948d9c..21ce1dfc18be 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx @@ -57,9 +57,9 @@ const Onboarding: FC = () => { {project && } @@ -69,7 +69,7 @@ const Onboarding: FC = () => { size={ODS_THEME_TYPOGRAPHY_SIZE._400} className="mt-8 block" > - {t('not_created_message')} + {t('pci_instances_onboarding_not_created_message')} { size={ODS_THEME_TYPOGRAPHY_SIZE._500} className="mt-8 block" > - {t('content_message_1')} + {t('pci_instances_onboarding_content_message_1')} { size={ODS_THEME_TYPOGRAPHY_SIZE._400} className="mt-4 block" > - {t('content_message_2')} + {t('pci_instances_onboarding_content_message_2')} { size={ODS_THEME_TYPOGRAPHY_SIZE._400} className="mt-8 block" > - {t('advice_message')} + {t('pci_instances_onboarding_advice_message')} } @@ -103,9 +103,11 @@ const Onboarding: FC = () => { key={guide.id} href={guide.links[ovhSubsidiary] ?? (guide.links.DEFAULT as string)} texts={{ - title: t(`${guide.id}_title`), - description: t(`${guide.id}_description`), - category: t('guide_title'), + title: t(`pci_instances_onboarding_${guide.id}_title`), + description: t( + `pci_instances_onboarding_${guide.id}_description`, + ), + category: t('pci_instances_onboarding_guide_title'), }} /> ))} From 3cbd603c973ac0078d43fd3e26dde47a657684db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 15 Oct 2024 10:20:41 +0200 Subject: [PATCH 59/76] fix(pci-instances): navigate to onboarding page smoother MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1920 Signed-off-by: Frédéric Vilcot --- .../src/pages/instances/Instances.page.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index cd0aa34f045a..63d4b66ce399 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -1,7 +1,7 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + Navigate, useHref, - useNavigate, useParams, useRouteLoaderData, } from 'react-router-dom'; @@ -58,7 +58,6 @@ const Instances: FC = () => { const { t } = useTranslation(['list', 'common']); const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader const project = useRouteLoaderData('root') as TProject; - const navigate = useNavigate(); const createInstanceHref = useHref('./new'); const [sorting, setSorting] = useState(initialSorting); const [searchField, setSearchField] = useState(''); @@ -89,6 +88,11 @@ const Instances: FC = () => { filters, }); + const onboardingUrl = useMemo( + () => `/pci/projects/${projectId}/instances/onboarding`, + [projectId], + ); + const textCell = useCallback( (props: TInstance, key: 'flavorName' | 'region' | 'imageName') => isRefetching ? ( @@ -104,11 +108,7 @@ const Instances: FC = () => { isRefetching ? ( ) : ( - +
    {props.addresses.get(key)?.map((item) => (
  • @@ -116,7 +116,7 @@ const Instances: FC = () => {
  • ))}
-
+ ), [isRefetching], ); @@ -274,11 +274,6 @@ const Instances: FC = () => { fetchNextPage(); }, [fetchNextPage]); - useEffect(() => { - if (data && !filters.length && !data.length && !isFetching) - navigate(`/pci/projects/${projectId}/instances/onboarding`); - }, [data, filters.length, isFetching, navigate, projectId]); - useEffect(() => { if (hasInconsistency) addWarning(t('inconsistency_message'), true); return () => { @@ -296,6 +291,9 @@ const Instances: FC = () => { if (isLoading) return ; + if (data && !data.length && !filters.length && !isFetching) + return ; + return ( {project && } @@ -357,7 +355,9 @@ const Instances: FC = () => { size={ODS_BUTTON_SIZE.sm} color={ODS_THEME_COLOR_INTENT.primary} variant={ODS_BUTTON_VARIANT.stroked} - {...((filters.length > 0 || isFetching) && { disabled: true })} + {...((filters.length > 0 || isFetching) && { + disabled: true, + })} > Date: Tue, 15 Oct 2024 10:22:05 +0200 Subject: [PATCH 60/76] fix(pci-instances): improve step validation & edition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1920 Signed-off-by: Frédéric Vilcot --- .../steps/model/ModelStep.component.tsx | 48 ++++-------- .../steps/model/StepTitle.component.tsx | 8 +- .../steps/region/RegionStep.component.tsx | 59 +++++++------- .../steps/region/StepTitle.component.tsx | 8 +- .../region/UnavailableRegions.component.tsx | 12 +-- .../src/store/slices/stepper.slice.ts | 77 +++++++++++++++---- 6 files changed, 116 insertions(+), 96 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx index f4b8f2fce172..8ca021391434 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx @@ -13,26 +13,11 @@ import { useCatalog } from '@/data/hooks/catalog/useCatalog'; import { TModelPricing, TPriceInterval } from '@/types/catalog/entity.types'; import { DeepReadonly } from '@/types/utils.type'; import { useAppStore } from '@/store/hooks/useAppStore'; -import { TStep, TStepId } from '@/store/slices/stepper.slice'; +import { TStepId } from '@/store/slices/stepper.slice'; import { StepTitle } from './StepTitle.component'; import { StepContent } from './StepContent.component'; const modelStepId: TStepId = 'model'; -const validatedModelStepState: Partial = { - isChecked: true, - isLocked: true, - isOpen: false, -}; -const editedModelStepState: Partial = { - isChecked: false, - isLocked: false, - isOpen: true, -}; -const regionStepOpen: Partial = { - isOpen: true, - isLocked: false, - isChecked: false, -}; const getModelPriceByInterval = ( pricings: DeepReadonly, @@ -55,11 +40,12 @@ export const ModelStep: FC = () => { }, ); - const { stepStateById, modelName, updateStep } = useAppStore( + const { stepById, modelName, editStep, validateStep } = useAppStore( useShallow((state) => ({ - stepStateById: state.stepStateById(), + stepById: state.stepById(), modelName: state.modelName(), - updateStep: state.updateStep, + editStep: state.editStep, + validateStep: state.validateStep, })), ); @@ -105,24 +91,20 @@ export const ModelStep: FC = () => { [handleRefetch, t], ); - const modelStepState = useMemo(() => stepStateById(modelStepId), [ - stepStateById, - ]); + const modelStep = useMemo(() => stepById(modelStepId), [stepById]); const handleNextStep = useCallback( (id: string) => { - updateStep(id as TStepId, validatedModelStepState); - updateStep('region', regionStepOpen); + validateStep(id as TStepId); }, - [updateStep], + [validateStep], ); const handleEditStep = useCallback( (id: string) => { - updateStep(id as TStepId, editedModelStepState); - updateStep('region', { isOpen: false }); + editStep(id as TStepId); }, - [updateStep], + [editStep], ); const handleError = useCallback(() => { @@ -138,13 +120,13 @@ export const ModelStep: FC = () => {
} diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepTitle.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepTitle.component.tsx index 3856c0265b14..6174530bd833 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepTitle.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/StepTitle.component.tsx @@ -6,12 +6,12 @@ import { useAppStore } from '@/store/hooks/useAppStore'; import { TStep } from '@/store/slices/stepper.slice'; type TStepTitleProps = DeepReadonly<{ - modelStepState?: TStep; + modelStep?: TStep; modelMonthlyPrice?: string; }>; export const StepTitle: FC = ({ - modelStepState, + modelStep, modelMonthlyPrice, }) => { const { t } = useTranslation('models'); @@ -24,12 +24,12 @@ export const StepTitle: FC = ({ const modelStepTitle = useMemo( () => - modelName && !modelStepState?.isOpen + modelName && !modelStep?.isOpen ? `${t('pci_instances_models_chosen_model_message', { model: modelName.toUpperCase(), })} ${modelMonthlyPrice || ''}` : t('pci_instances_models_select_template'), - [modelName, modelStepState?.isOpen, t, modelMonthlyPrice], + [modelName, modelStep?.isOpen, t, modelMonthlyPrice], ); return modelStepTitle; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx index dbc473663b6a..169f92ce5254 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx @@ -11,7 +11,7 @@ import { useCatalog, } from '@/data/hooks/catalog/useCatalog'; import { useAppStore } from '@/store/hooks/useAppStore'; -import { TStep, TStepId } from '@/store/slices/stepper.slice'; +import { TStepId } from '@/store/slices/stepper.slice'; import { useActivateRegion } from '@/data/hooks/region/useActivateRegion'; import queryClient from '@/queryClient'; import { StepTitle } from './StepTitle.component'; @@ -20,16 +20,6 @@ import { StepContent } from './StepContent.component'; type TgetRegionActivatedStatus = 'loading' | 'success' | 'error'; const regionStepId: TStepId = 'region'; -const validatedRegionStepState: Partial = { - isChecked: true, - isLocked: true, - isOpen: false, -}; -const editedRegionStepState: Partial = { - isChecked: false, - isLocked: false, - isOpen: true, -}; export const RegionStep: FC = () => { const { t } = useTranslation(['regions', 'stepper']); @@ -44,17 +34,19 @@ export const RegionStep: FC = () => { const { modelName, - stepStateById, + stepById, selectedRegion, setRegion, - updateStep, + editStep, + validateStep, } = useAppStore( useShallow((state) => ({ modelName: state.modelName(), - stepStateById: state.stepStateById(), - updateStep: state.updateStep, + stepById: state.stepById(), selectedRegion: state.region(), setRegion: state.setRegion, + editStep: state.editStep, + validateStep: state.validateStep, })), ); const { data } = useCatalog<'regionSelector'>(projectId, { @@ -74,15 +66,15 @@ export const RegionStep: FC = () => { (regionName: string) => { clearNotifications(); updateCatalogQueryData(queryClient, projectId, regionName); - addSuccess(getRegionActivationdMessage('success')); - updateStep(regionStepId, validatedRegionStepState); + addSuccess(getRegionActivationdMessage('success'), true); + validateStep(regionStepId); }, [ addSuccess, clearNotifications, getRegionActivationdMessage, projectId, - updateStep, + validateStep, ], ); @@ -103,9 +95,7 @@ export const RegionStep: FC = () => { if (isPending) addInfo(getRegionActivationdMessage('loading')); }, [addInfo, getRegionActivationdMessage, isPending]); - const regionStepState = useMemo(() => stepStateById(regionStepId), [ - stepStateById, - ]); + const regionStep = useMemo(() => stepById(regionStepId), [stepById]); const isSelectedRegionActivated = useMemo( (): boolean => @@ -135,7 +125,7 @@ export const RegionStep: FC = () => { if (!isSelectedRegionActivated) { activateRegion(selectedRegion.name); } else { - updateStep(regionStepId, validatedRegionStepState); + validateStep(regionStepId); } } }, [ @@ -143,15 +133,14 @@ export const RegionStep: FC = () => { clearNotifications, isSelectedRegionActivated, selectedRegion, - updateStep, + validateStep, ]); const handleEditStep = useCallback( (id: string) => { - clearNotifications(); - updateStep(id as TStepId, editedRegionStepState); + editStep(id as TStepId); }, - [clearNotifications, updateStep], + [editStep], ); useEffect(() => { @@ -168,18 +157,24 @@ export const RegionStep: FC = () => { handleRegionActivationLoading, ]); + useEffect(() => { + if (regionStep?.isOpen) { + clearNotifications(); + } + }, [regionStep?.isOpen, clearNotifications]); + return (
} {...(!isPending && { @@ -189,7 +184,7 @@ export const RegionStep: FC = () => { isDisabled: !selectedRegion, }, })} - {...(regionStepState?.isChecked && { + {...(regionStep?.isChecked && { edit: { action: handleEditStep, label: t('stepper:pci_instances_stepper_edit_step_label'), diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepTitle.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepTitle.component.tsx index 50e72fe4a2f5..d053616b79c2 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepTitle.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/StepTitle.component.tsx @@ -12,12 +12,12 @@ import { TStep } from '@/store/slices/stepper.slice'; type TStepTitleProps = DeepReadonly<{ isNotificationOpen: boolean; - regionStepState?: TStep; + regionStep?: TStep; }>; export const StepTitle: FC = ({ isNotificationOpen, - regionStepState, + regionStep, }) => { const { t } = useTranslation('regions'); const { translateMacroRegion } = useTranslatedMicroRegions(); @@ -30,12 +30,12 @@ export const StepTitle: FC = ({ const regionStepTitle = useMemo( () => - selectedRegion && !regionStepState?.isOpen + selectedRegion && !regionStep?.isOpen && regionStep?.isChecked ? t('pci_instances_regions_chosen_region_message', { region: translateMacroRegion(selectedRegion.name), }) : t('pci_instances_regions_select_localization'), - [regionStepState?.isOpen, selectedRegion, t, translateMacroRegion], + [regionStep, selectedRegion, t, translateMacroRegion], ); return ( diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/UnavailableRegions.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/UnavailableRegions.component.tsx index 9c794ebefa32..532529be42ee 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/UnavailableRegions.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/UnavailableRegions.component.tsx @@ -74,9 +74,9 @@ export const UnavailableRegions: FC = ({ const [checked, setChecked] = useState(false); const { t } = useTranslation('regions'); - const { updateStep } = useAppStore( + const { editStep } = useAppStore( useShallow((state) => ({ - updateStep: state.updateStep, + editStep: state.editStep, })), ); @@ -86,12 +86,8 @@ export const UnavailableRegions: FC = ({ ); const handleChangeModelClick = useCallback(() => { - updateStep('region', { - isOpen: false, - isLocked: true, - }); - updateStep('model', { isOpen: true, isLocked: false, isChecked: false }); - }, [updateStep]); + editStep('model'); + }, [editStep]); return ( <> diff --git a/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts b/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts index 31217989171e..5f7a30ba51f3 100644 --- a/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts +++ b/packages/manager/apps/pci-instances/src/store/slices/stepper.slice.ts @@ -2,12 +2,16 @@ import { StateCreator } from 'zustand'; export type TStepId = 'model' | 'region'; -export type TStep = { +export type TStepState = { isOpen: boolean; isChecked: boolean; isLocked: boolean; }; +export type TStep = TStepState & { + order: number; +}; + export type TSteps = Map; export type TState = { @@ -19,25 +23,37 @@ export type TState = { * If used with a parameter, currification enables reactivity for getters. */ export type TQuery = { - stepStateById: () => (stepId: TStepId) => TStep | undefined; + stepById: () => (stepId: TStepId) => TStep | undefined; }; // Handlers export type TCommand = { - updateStep: (stepId: TStepId, step: Partial) => void; + editStep: (stepId: TStepId) => void; + validateStep: (stepId: TStepId) => void; }; export type TStepperSlice = TQuery & TState & TCommand; -const initStep = (isOpen: boolean): TStep => ({ - isOpen, +const inactiveStepState: TStepState = { + isOpen: false, isChecked: false, isLocked: false, -}); +}; + +const editStepState: TStepState = { + ...inactiveStepState, + isOpen: true, +}; + +const validateStepState: TStepState = { + ...inactiveStepState, + isChecked: true, + isLocked: true, +}; const initialSteps = new Map([ - ['model', initStep(true)], - ['region', initStep(false)], + ['model', { ...editStepState, order: 1 }], + ['region', { ...inactiveStepState, order: 2 }], ]); export const createStepperSlice: StateCreator< @@ -47,19 +63,50 @@ export const createStepperSlice: StateCreator< TStepperSlice > = (set, get) => ({ steps: initialSteps, - stepStateById: () => (stepId) => get().steps.get(stepId), - updateStep: (stepId, step) => + stepById: () => (stepId) => get().steps.get(stepId), + editStep: (stepId) => + set((state) => { + const newSteps = new Map(); + const activeStep = state.steps.get(stepId); + if (activeStep) { + state.steps.forEach((value, key) => { + if (key === stepId) { + newSteps.set(stepId, { + ...activeStep, + ...editStepState, + }); + } else if (value.order > activeStep.order) { + newSteps.set(key, { + ...value, + ...inactiveStepState, + }); + } else { + newSteps.set(key, { ...value, isOpen: false }); + } + }); + } + return { steps: newSteps }; + }), + validateStep: (stepId) => set((state) => { const newSteps = new Map(state.steps); const activeStep = newSteps.get(stepId); + if (activeStep) { - const updatedSteps = newSteps.set(stepId, { + const nextStep = [...newSteps].find( + (elt) => elt[1].order === activeStep.order + 1, + ); + newSteps.set(stepId, { ...activeStep, - ...(step.isOpen !== undefined && { isOpen: step.isOpen }), - ...(step.isChecked !== undefined && { isChecked: step.isChecked }), - ...(step.isLocked !== undefined && { isLocked: step.isLocked }), + ...validateStepState, }); - return { steps: updatedSteps }; + if (nextStep) { + const [key, value] = nextStep; + newSteps.set(key, { + ...value, + ...editStepState, + }); + } } return { steps: newSteps }; }), From 86e4d8fb5b50cecaeca351480f1a9578db0f0737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 15 Oct 2024 10:22:48 +0200 Subject: [PATCH 61/76] test(pci-instances): update Stepper slice test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1920 Signed-off-by: Frédéric Vilcot --- .../src/store/hooks/useAppStore.spec.ts | 149 ++++++++++++------ 1 file changed, 103 insertions(+), 46 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts index ce90b39b4a46..7a916ac062b9 100644 --- a/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts +++ b/packages/manager/apps/pci-instances/src/store/hooks/useAppStore.spec.ts @@ -1,7 +1,7 @@ import { describe, test } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useAppStore } from './useAppStore'; -import { TStep, TStepId } from '../slices/stepper.slice'; +import { TStep, TStepId, TSteps } from '../slices/stepper.slice'; import { TRegionItem } from '../slices/form.slice'; describe('Considering the useAppStore hook', () => { @@ -60,68 +60,125 @@ describe('Considering the useAppStore hook', () => { ); }); - describe("Considering the 'StepperSlice'", () => { - // test data - type Data = { + describe('Considering the Stepper slice', () => { + type Data1 = { stepId: TStepId; - updatedStep?: TStep; - updatedStepId?: TStepId; - expectedStep: TStep; - expectedUpdatedStep?: TStep; + expectedStepById: TStep; + }; + + type Data2 = { + validateStepId?: TStepId; + expectedValidatedStepById?: TStep; + editStepId: TStepId; + expectedEditedStepById: TStep; }; - const stepId1: TStepId = 'model'; - const updatedStepId1: TStepId = stepId1; - const updatedStepId2 = 'foo'; + + const initialSteps: TSteps = new Map([ + [ + 'model', + { + isOpen: true, + isChecked: false, + isLocked: false, + order: 1, + }, + ], + [ + 'region', + { + isOpen: false, + isChecked: false, + isLocked: false, + order: 2, + }, + ], + ]); const expectedStep1: TStep = { + order: 1, isChecked: false, isLocked: false, isOpen: true, }; - const updatedStep1: TStep = { + const expectedStep2: TStep = { + order: 2, + isChecked: false, + isLocked: false, + isOpen: false, + }; + + const expectedValidatedStep1: TStep = { + order: 1, isChecked: true, isLocked: true, isOpen: false, }; - const expectedUpdatedStep1 = updatedStep1; + const expectedEditedStep1: TStep = { + order: 2, + isChecked: false, + isLocked: false, + isOpen: true, + }; + + const expectedEditedStep2: TStep = { + order: 1, + isChecked: false, + isLocked: false, + isOpen: true, + }; - describe.each` - stepId | expectedStep | updatedStepId | updatedStep | expectedUpdatedStep - ${undefined} | ${undefined} | ${undefined} | ${undefined} | ${undefined} - ${stepId1} | ${expectedStep1} | ${undefined} | ${undefined} | ${undefined} - ${stepId1} | ${expectedStep1} | ${updatedStepId1} | ${updatedStep1} | ${expectedUpdatedStep1} - ${stepId1} | ${expectedStep1} | ${updatedStepId2} | ${updatedStep1} | ${expectedStep1} + test('Should create the steppe slice with initial steps', () => { + const { result } = renderHook(() => useAppStore()); + expect(result.current).toHaveProperty('steps'); + expect(result.current.steps).toBeInstanceOf(Map); + expect(result.current.steps).toStrictEqual(initialSteps); + }); + + test.each` + stepId | expectedStepById + ${undefined} | ${undefined} + ${'model'} | ${expectedStep1} + ${'region'} | ${expectedStep2} `( - 'Given a stepId <$stepId>', + 'Given a stepId parameter <$stepId>, expect stepById() query function to return <$expectedStepById>', + ({ stepId, expectedStepById }: Data1) => { + const { result } = renderHook(() => useAppStore()); + expect(result.current.stepById()(stepId)).toStrictEqual( + expectedStepById, + ); + }, + ); + + test.each` + validateStepId | expectedValidatedStepById | editStepId | expectedEditedStepById + ${undefined} | ${undefined} | ${undefined} | ${undefined} + ${'model'} | ${expectedValidatedStep1} | ${'region'} | ${expectedEditedStep1} + ${undefined} | ${undefined} | ${'model'} | ${expectedEditedStep2} + `( + `Given a validateStepId parameter <$validateStepId> to validate a editStepId parameter <$editStepId> to edit, + expect stepById() query function to return the validated step <$expectedValidatedStepById> and the edited step <$expectedEditedStepById>`, ({ - stepId, - expectedStep, - updatedStep, - expectedUpdatedStep, - updatedStepId, - }: Data) => { - describe(`When invoking useAppStore hook`, () => { - test(`Then, expect step to be ${JSON.stringify( - expectedStep, - )}`, () => { - const { result } = renderHook(() => useAppStore()); - expect(result.current).toHaveProperty('steps'); - expect(result.current.steps).toBeInstanceOf(Map); - expect(result.current.stepStateById()(stepId)).toStrictEqual( - expectedStep, - ); - if (updatedStep && updatedStepId) { - act(() => { - result.current.updateStep(updatedStepId, updatedStep); - }); - expect(result.current.stepStateById()(stepId)).toStrictEqual( - expectedUpdatedStep, - ); - } - }); - }); + validateStepId, + expectedValidatedStepById, + editStepId, + expectedEditedStepById, + }: Data2) => { + const { result } = renderHook(() => useAppStore()); + if (validateStepId) { + act(() => result.current.validateStep(validateStepId)); + expect(result.current.stepById()(validateStepId)).toStrictEqual( + expectedValidatedStepById, + ); + expect(result.current.stepById()(editStepId)).toStrictEqual( + expectedEditedStepById, + ); + } + act(() => result.current.editStep(editStepId)); + expect(result.current.stepById()(editStepId)).toStrictEqual( + expectedEditedStepById, + ); }, ); }); From c9a5bbeb47b0a4d0e5d33ee75570081dd70b1333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Wed, 16 Oct 2024 10:50:42 +0200 Subject: [PATCH 62/76] refactor(pci-instances): improve code quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1920 Signed-off-by: Frédéric Vilcot --- .../src/pages/instances/Instances.page.tsx | 10 ++++----- .../steps/model/ModelStep.component.tsx | 22 +++++++------------ .../steps/region/RegionStep.component.tsx | 14 +++++------- 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index 63d4b66ce399..e07c4f63dcda 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -54,6 +54,9 @@ const initialSorting = { desc: false, }; +const getOnboardingUrl = (projectId: string) => + `/pci/projects/${projectId}/instances/onboarding`; + const Instances: FC = () => { const { t } = useTranslation(['list', 'common']); const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader @@ -88,11 +91,6 @@ const Instances: FC = () => { filters, }); - const onboardingUrl = useMemo( - () => `/pci/projects/${projectId}/instances/onboarding`, - [projectId], - ); - const textCell = useCallback( (props: TInstance, key: 'flavorName' | 'region' | 'imageName') => isRefetching ? ( @@ -292,7 +290,7 @@ const Instances: FC = () => { if (isLoading) return ; if (data && !data.length && !filters.length && !isFetching) - return ; + return ; return ( diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx index 8ca021391434..b971d9545c31 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/model/ModelStep.component.tsx @@ -49,6 +49,8 @@ export const ModelStep: FC = () => { })), ); + const modelStep = stepById(modelStepId); + const getSelectedModelMonthlyPrice = useCallback((): string | undefined => { const selectedModelMonthlyPrice = getModelPriceByInterval( data?.models.data.find((model) => model.name === modelName)?.pricings ?? @@ -91,21 +93,13 @@ export const ModelStep: FC = () => { [handleRefetch, t], ); - const modelStep = useMemo(() => stepById(modelStepId), [stepById]); - - const handleNextStep = useCallback( - (id: string) => { - validateStep(id as TStepId); - }, - [validateStep], - ); + const handleNextStep = (id: string) => { + validateStep(id as TStepId); + }; - const handleEditStep = useCallback( - (id: string) => { - editStep(id as TStepId); - }, - [editStep], - ); + const handleEditStep = (id: string) => { + editStep(id as TStepId); + }; const handleError = useCallback(() => { if (isError) addError(errorMessage); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx index 169f92ce5254..1de48d2878e0 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/steps/region/RegionStep.component.tsx @@ -49,6 +49,9 @@ export const RegionStep: FC = () => { validateStep: state.validateStep, })), ); + + const regionStep = stepById(regionStepId); + const { data } = useCatalog<'regionSelector'>(projectId, { selector: 'regionSelector', enabled: modelName !== null, @@ -95,8 +98,6 @@ export const RegionStep: FC = () => { if (isPending) addInfo(getRegionActivationdMessage('loading')); }, [addInfo, getRegionActivationdMessage, isPending]); - const regionStep = useMemo(() => stepById(regionStepId), [stepById]); - const isSelectedRegionActivated = useMemo( (): boolean => !!selectedRegion && @@ -136,12 +137,9 @@ export const RegionStep: FC = () => { validateStep, ]); - const handleEditStep = useCallback( - (id: string) => { - editStep(id as TStepId); - }, - [editStep], - ); + const handleEditStep = (id: string) => { + editStep(id as TStepId); + }; useEffect(() => { handleSelectedRegion(); From 65d9f479e8c534b428be2b95449a5c0022379fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 18 Oct 2024 10:45:23 +0200 Subject: [PATCH 63/76] feat(pci-instances): add :instanceId route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1928 Signed-off-by: Frédéric Vilcot --- packages/manager/apps/pci-instances/src/routes/routes.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/manager/apps/pci-instances/src/routes/routes.tsx b/packages/manager/apps/pci-instances/src/routes/routes.tsx index 2c8325d76fa5..b7ec5aa3f4a8 100644 --- a/packages/manager/apps/pci-instances/src/routes/routes.tsx +++ b/packages/manager/apps/pci-instances/src/routes/routes.tsx @@ -17,6 +17,7 @@ export const ROOT_PATH = '/pci/projects/:projectId/instances'; export const SUB_PATHS = { onboarding: 'onboarding', new: 'new', + instance: ':instanceId', }; const routes: RouteObject[] = [ @@ -47,6 +48,12 @@ const routes: RouteObject[] = [ import('@/pages/instances/create/CreateInstance.page'), ), }, + { + path: SUB_PATHS.instance, + ...lazyRouteConfig(() => + import('@/pages/instances/instance/Instance.page'), + ), + }, ], }, { From a3342735a2d2df320b2828e4e83e768225f78431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 18 Oct 2024 10:47:51 +0200 Subject: [PATCH 64/76] feat(pci-instances): add instance page & cell components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1928 Signed-off-by: Frédéric Vilcot --- .../datagrid/cell/LoadingCell.component.tsx | 12 ++ .../datagrid/cell/TextCell.component.tsx | 31 ++++ .../components/menu/ActionsMenu.component.tsx | 67 ++++++++ .../navigation/GoBack.component.tsx | 27 +++ .../src/pages/instances/Instances.page.tsx | 159 ++++++++---------- .../instances/create/CreateInstance.page.tsx | 27 +-- .../datagrid/cell/ActionsCell.component.tsx | 29 ++++ .../datagrid/cell/AddressesCell.component.tsx | 20 +++ .../datagrid/cell/NameIdCell.component.tsx | 25 +++ .../datagrid/cell/StatusCell.component.tsx | 15 ++ .../instances/instance/Instance.page.tsx | 10 ++ .../instances/onboarding/Onboarding.page.tsx | 8 +- 12 files changed, 309 insertions(+), 121 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/components/datagrid/cell/LoadingCell.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/menu/ActionsMenu.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/navigation/GoBack.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/AddressesCell.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/NameIdCell.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/StatusCell.component.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/instance/Instance.page.tsx diff --git a/packages/manager/apps/pci-instances/src/components/datagrid/cell/LoadingCell.component.tsx b/packages/manager/apps/pci-instances/src/components/datagrid/cell/LoadingCell.component.tsx new file mode 100644 index 000000000000..d46e1356a0d3 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/datagrid/cell/LoadingCell.component.tsx @@ -0,0 +1,12 @@ +import { OsdsSkeleton } from '@ovhcloud/ods-components/react'; +import { FC, PropsWithChildren } from 'react'; + +export type TLoadingCellProps = PropsWithChildren<{ + isLoading: boolean; +}>; + +export const LoadingCell: FC = ({ isLoading, children }) => ( +
+ {isLoading ? : children} +
+); diff --git a/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.component.tsx b/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.component.tsx new file mode 100644 index 000000000000..7c172d0bb0b8 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.component.tsx @@ -0,0 +1,31 @@ +import { + ODS_THEME_TYPOGRAPHY_LEVEL, + ODS_THEME_TYPOGRAPHY_SIZE, + ODS_THEME_COLOR_INTENT, +} from '@ovhcloud/ods-common-theming'; +import { OsdsText } from '@ovhcloud/ods-components/react'; +import { FC, PropsWithChildren } from 'react'; +import { LoadingCell } from './LoadingCell.component'; + +export const BaseTextCell: FC = ({ children }) => ( + + {children} + +); + +type TTextCellProps = { + isLoading: boolean; + label: string; +}; + +export const TextCell: FC = ({ isLoading, label }) => ( + + {label} + +); diff --git a/packages/manager/apps/pci-instances/src/components/menu/ActionsMenu.component.tsx b/packages/manager/apps/pci-instances/src/components/menu/ActionsMenu.component.tsx new file mode 100644 index 000000000000..583b99948f1e --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/menu/ActionsMenu.component.tsx @@ -0,0 +1,67 @@ +import { + ODS_THEME_COLOR_INTENT, + ODS_THEME_TYPOGRAPHY_SIZE, +} from '@ovhcloud/ods-common-theming'; +import { + ODS_BUTTON_VARIANT, + ODS_ICON_NAME, + ODS_ICON_SIZE, + ODS_BUTTON_SIZE, + ODS_TEXT_LEVEL, +} from '@ovhcloud/ods-components'; +import { + OsdsButton, + OsdsIcon, + OsdsMenu, + OsdsMenuItem, + OsdsText, +} from '@ovhcloud/ods-components/react'; +import { FC } from 'react'; + +export type TActionsMenuItem = { + label: string; + href?: string; + onMenuItemClick?: () => void; +}; +export type TActionsMenuProps = { + items: TActionsMenuItem[]; +}; + +export const ActionsMenu: FC = ({ items }) => ( + + + + + {items.map(({ label, href, onMenuItemClick }) => ( + + + + {label} + + + + ))} + +); diff --git a/packages/manager/apps/pci-instances/src/components/navigation/GoBack.component.tsx b/packages/manager/apps/pci-instances/src/components/navigation/GoBack.component.tsx new file mode 100644 index 000000000000..5659a1a78a45 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/navigation/GoBack.component.tsx @@ -0,0 +1,27 @@ +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { ODS_ICON_NAME, ODS_ICON_SIZE } from '@ovhcloud/ods-components'; +import { OsdsIcon, OsdsLink } from '@ovhcloud/ods-components/react'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHref } from 'react-router-dom'; + +export const GoBack: FC = () => { + const { t } = useTranslation('common'); + const backHref = useHref('..'); + return ( + + + {t('pci_instances_common_go_back')} + + ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index e07c4f63dcda..97f77c5377d4 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -9,7 +9,6 @@ import { TProject } from '@ovh-ux/manager-pci-common'; import { Datagrid, DatagridColumn, - DataGridTextCell, FilterAdd, FilterList, Notifications, @@ -18,12 +17,9 @@ import { Title, useColumnFilters, useNotifications, + useTranslatedMicroRegions, } from '@ovh-ux/manager-react-components'; -import { - ODS_THEME_COLOR_INTENT, - ODS_THEME_TYPOGRAPHY_LEVEL, - ODS_THEME_TYPOGRAPHY_SIZE, -} from '@ovhcloud/ods-common-theming'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; import { OsdsButton, OsdsDivider, @@ -31,8 +27,6 @@ import { OsdsPopover, OsdsPopoverContent, OsdsSearchBar, - OsdsText, - OsdsSkeleton, OsdsLink, } from '@ovhcloud/ods-components/react'; import { @@ -46,17 +40,19 @@ import { Trans, useTranslation } from 'react-i18next'; import { FilterComparator } from '@ovh-ux/manager-core-api'; import { Spinner } from '@/components/spinner/Spinner.component'; import { TInstance, useInstances } from '@/data/hooks/instance/useInstances'; -import StatusChip from '@/components/statusChip/StatusChip.component'; import { Breadcrumb } from '@/components/breadcrumb/Breadcrumb.component'; +import { SUB_PATHS } from '@/routes/routes'; +import { StatusCell } from './datagrid/cell/StatusCell.component'; +import { ActionsCell } from './datagrid/cell/ActionsCell.component'; +import { NameIdCell } from './datagrid/cell/NameIdCell.component'; +import { TextCell } from '@/components/datagrid/cell/TextCell.component'; +import { AddressesCell } from './datagrid/cell/AddressesCell.component'; const initialSorting = { id: 'name', desc: false, }; -const getOnboardingUrl = (projectId: string) => - `/pci/projects/${projectId}/instances/onboarding`; - const Instances: FC = () => { const { t } = useTranslation(['list', 'common']); const { projectId } = useParams() as { projectId: string }; // safe because projectId has already been handled by async route loader @@ -72,10 +68,11 @@ const Instances: FC = () => { addError, } = useNotifications(); const filterPopoverRef = useRef(null); + const { translateMicroRegion } = useTranslatedMicroRegions(); const { data, - isLoading, + isLoading: instancesQueryLoading, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -91,124 +88,100 @@ const Instances: FC = () => { filters, }); - const textCell = useCallback( - (props: TInstance, key: 'flavorName' | 'region' | 'imageName') => - isRefetching ? ( - - ) : ( - {props[key]} - ), - [isRefetching], - ); - - const listCell = useCallback( - (props: TInstance, key: 'public' | 'private') => - isRefetching ? ( - - ) : ( - -
    - {props.addresses.get(key)?.map((item) => ( -
  • - {item.ip} -
  • - ))} -
-
- ), - [isRefetching], - ); - const datagridColumns: DatagridColumn[] = useMemo( () => [ { id: 'name', - cell: (props) => - isRefetching ? ( - - ) : ( - <> - - {props.name} - - - {props.id} - - - ), - label: t('nameId'), + cell: (instance) => ( + + ), + label: t('pci_instances_list_column_nameId'), isSortable: true, }, { id: 'region', - cell: (props: TInstance) => textCell(props, 'region'), - label: t('region'), + cell: (instance) => ( + + ), + label: t('pci_instances_list_column_region'), isSortable: false, }, { id: 'flavor', - cell: (props: TInstance) => textCell(props, 'flavorName'), - label: t('flavor'), + cell: (instance) => ( + + ), + label: t('pci_instances_list_column_flavor'), isSortable: true, }, { id: 'image', - cell: (props: TInstance) => textCell(props, 'imageName'), - label: t('image'), + cell: (instance) => ( + + ), + label: t('pci_instances_list_column_image'), isSortable: true, }, { id: 'publicIPs', - cell: (props: TInstance) => listCell(props, 'public'), - label: t('public_IPs'), + cell: (instance) => ( + + ), + label: t('pci_instances_list_column_public_IPs'), isSortable: false, }, { id: 'privateIPs', - cell: (props: TInstance) => listCell(props, 'private'), - label: t('private_IPs'), + cell: (instance) => ( + + ), + label: t('pci_instances_list_column_private_IPs'), isSortable: false, }, { id: 'status', - cell: (props: TInstance) => - isRefetching ? ( - - ) : ( - - ), - label: t('status'), + cell: (instance) => ( + + ), + label: t('pci_instances_list_column_status'), + isSortable: false, + }, + { + id: 'actions', + cell: (instance) => ( + + ), + label: t('pci_instances_list_column_actions'), isSortable: false, }, ], - [isRefetching, listCell, t, textCell], + [isRefetching, t, translateMicroRegion], ); const filterColumns = useMemo( () => [ { id: 'name', - label: t('nameId'), + label: t('pci_instances_list_column_nameId'), comparators: [FilterComparator.Includes], }, { id: 'flavor', - label: t('flavor'), + label: t('pci_instances_list_column_flavor'), comparators: [FilterComparator.Includes], }, { id: 'image', - label: t('image'), + label: t('pci_instances_list_column_image'), comparators: [FilterComparator.Includes], }, ], @@ -231,7 +204,7 @@ const Instances: FC = () => { <> { }} />
- + ), [handleRefresh, t], @@ -273,7 +246,8 @@ const Instances: FC = () => { }, [fetchNextPage]); useEffect(() => { - if (hasInconsistency) addWarning(t('inconsistency_message'), true); + if (hasInconsistency) + addWarning(t('pci_instances_list_inconsistency_message'), true); return () => { clearNotifications(); }; @@ -287,10 +261,10 @@ const Instances: FC = () => { if (isError) addError(errorMessage, true); }, [isError, addError, t, errorMessage]); - if (isLoading) return ; + if (instancesQueryLoading) return ; if (data && !data.length && !filters.length && !isFetching) - return ; + return ; return ( @@ -363,7 +337,7 @@ const Instances: FC = () => { className={'mr-2'} color={ODS_THEME_COLOR_INTENT.primary} /> - {t('filter')} + {t('common:pci_instances_common_filter')} { sorting={sorting} onSortChange={setSorting} manualSorting + className={'!overflow-x-visible'} />
)} diff --git a/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx index 9661a4cf5387..60f9b66a1d3a 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/create/CreateInstance.page.tsx @@ -1,15 +1,9 @@ import { PageLayout, Title } from '@ovh-ux/manager-react-components'; import { FC, useMemo } from 'react'; -import { useHref, useRouteLoaderData } from 'react-router-dom'; +import { useRouteLoaderData } from 'react-router-dom'; import { TProject } from '@ovh-ux/manager-pci-common'; import { useTranslation } from 'react-i18next'; -import { - OsdsDivider, - OsdsIcon, - OsdsLink, -} from '@ovhcloud/ods-components/react'; -import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; -import { ODS_ICON_NAME, ODS_ICON_SIZE } from '@ovhcloud/ods-components'; +import { OsdsDivider } from '@ovhcloud/ods-components/react'; import { Breadcrumb, TBreadcrumbProps, @@ -17,10 +11,10 @@ import { import { useHidePreloader } from '@/hooks/hidePreloader/useHidePreloader'; import { ModelStep } from './steps/model/ModelStep.component'; import { RegionStep } from './steps/region/RegionStep.component'; +import { GoBack } from '@/components/navigation/GoBack.component'; const CreateInstance: FC = () => { const project = useRouteLoaderData('root') as TProject; - const backHref = useHref('..'); const { t } = useTranslation('common'); const breadcrumbItems = useMemo( () => [ @@ -41,20 +35,7 @@ const CreateInstance: FC = () => { items={breadcrumbItems} /> )} - - - {t('pci_instances_common_go_back')} - +
{t('pci_instances_common_create_instance')}
diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx new file mode 100644 index 000000000000..d431b6f3b468 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHref } from 'react-router-dom'; +import { TInstance } from '@/data/hooks/instance/useInstances'; +import { + TActionsMenuItem, + ActionsMenu, +} from '@/components/menu/ActionsMenu.component'; +import { LoadingCell } from '@/components/datagrid/cell/LoadingCell.component'; + +type TActionsCellProps = { + instance: TInstance; + isLoading: boolean; +}; + +export const ActionsCell: FC = ({ isLoading, instance }) => { + const { t } = useTranslation('list'); + const items: TActionsMenuItem[] = [ + { + label: t('pci_instances_list_action_instance_details'), + href: useHref(instance.id), + }, + ]; + return ( + + + + ); +}; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/AddressesCell.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/AddressesCell.component.tsx new file mode 100644 index 000000000000..a834bb4a8b79 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/AddressesCell.component.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react'; +import { LoadingCell } from '@/components/datagrid/cell/LoadingCell.component'; +import { BaseTextCell } from '@/components/datagrid/cell/TextCell.component'; +import { TAddress } from '@/data/hooks/instance/useInstances'; +import { DeepReadonly } from '@/types/utils.type'; + +export type TAddressesCellProps = DeepReadonly<{ + isLoading: boolean; + addresses: TAddress[]; +}>; +export const AddressesCell: FC = ({ + isLoading, + addresses, +}) => ( + + {addresses.map((address) => ( + {address.ip} + ))} + +); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/NameIdCell.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/NameIdCell.component.tsx new file mode 100644 index 000000000000..ffe4ffbcac3a --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/NameIdCell.component.tsx @@ -0,0 +1,25 @@ +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { OsdsLink } from '@ovhcloud/ods-components/react'; +import { FC } from 'react'; +import { useHref } from 'react-router-dom'; +import { BaseTextCell } from '@/components/datagrid/cell/TextCell.component'; +import { LoadingCell } from '@/components/datagrid/cell/LoadingCell.component'; +import { TInstance } from '@/data/hooks/instance/useInstances'; + +type TNameIdCellProps = { + instance: TInstance; + isLoading: boolean; +}; + +export const NameIdCell: FC = ({ isLoading, instance }) => ( + + + {instance.name} + + {instance.id} + +); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/StatusCell.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/StatusCell.component.tsx new file mode 100644 index 000000000000..fc3941502fbd --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/StatusCell.component.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { LoadingCell } from '@/components/datagrid/cell/LoadingCell.component'; +import StatusChip from '@/components/statusChip/StatusChip.component'; +import { TInstance } from '@/data/hooks/instance/useInstances'; + +type TStatusCellProps = { + instance: TInstance; + isLoading: boolean; +}; + +export const StatusCell: FC = ({ isLoading, instance }) => ( + + + +); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/instance/Instance.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/instance/Instance.page.tsx new file mode 100644 index 000000000000..a338cbeb33ed --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/instance/Instance.page.tsx @@ -0,0 +1,10 @@ +import { PageLayout } from '@ovh-ux/manager-react-components'; +import { FC } from 'react'; +import { GoBack } from '@/components/navigation/GoBack.component'; + +const Instance: FC = () => ( + + + +); +export default Instance; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx index 21ce1dfc18be..6d9657aa663d 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/onboarding/Onboarding.page.tsx @@ -1,4 +1,4 @@ -import { FC, useContext, useMemo } from 'react'; +import { FC, useContext } from 'react'; import { Card, OnboardingLayout, @@ -45,14 +45,10 @@ const Onboarding: FC = () => { filters: [], }); - const rootUrl = useMemo(() => `/pci/projects/${projectId}/instances`, [ - projectId, - ]); - if (isLoading) return ; return data && data.length > 0 ? ( - + ) : ( {project && } From cca799008edeb2a7fa6d1633df2c1cb75d2d3d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 18 Oct 2024 10:49:16 +0200 Subject: [PATCH 65/76] test(pci-instances): add test suites for components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1928 Signed-off-by: Frédéric Vilcot --- .../src/__mocks__/instance/constants.ts | 21 ++++++++++ .../datagrid/cell/LoadingCell.spec.tsx | 29 +++++++++++++ .../datagrid/cell/TextCell.spec.tsx | 23 ++++++++++ .../src/components/menu/ActionsMenu.spec.tsx | 42 +++++++++++++++++++ .../src/components/navigation/GoBack.spec.tsx | 33 +++++++++++++++ .../datagrid/cell/ActionsCell.spec.tsx | 16 +++++++ .../datagrid/cell/AddressesCell.spec.tsx | 26 ++++++++++++ .../datagrid/cell/NameIdCell.spec.tsx | 19 +++++++++ .../datagrid/cell/StatusCell.spec.tsx | 12 ++++++ 9 files changed, 221 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/__mocks__/instance/constants.ts create mode 100644 packages/manager/apps/pci-instances/src/components/datagrid/cell/LoadingCell.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/menu/ActionsMenu.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/components/navigation/GoBack.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/AddressesCell.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/NameIdCell.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/StatusCell.spec.tsx diff --git a/packages/manager/apps/pci-instances/src/__mocks__/instance/constants.ts b/packages/manager/apps/pci-instances/src/__mocks__/instance/constants.ts new file mode 100644 index 000000000000..e33fb4cb8e39 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/__mocks__/instance/constants.ts @@ -0,0 +1,21 @@ +import { TInstance } from '@/data/hooks/instance/useInstances'; + +export const mockedInstance: TInstance = { + id: '12345', + name: 'foo', + flavorId: '678910', + flavorName: 'b2-8', + imageId: '11121314', + imageName: 'linux', + region: 'BHS', + status: { severity: 'success', state: 'ACTIVE' }, + addresses: new Map([ + ['private', [{ ip: '123.000.00', version: 1, gatewayIp: '' }]], + ['public', [{ ip: '777.000.00', version: 2, gatewayIp: '' }]], + ]), +}; + +export const mockedInstanceWithEmptyRegion: TInstance = { + ...mockedInstance, + region: '', +}; diff --git a/packages/manager/apps/pci-instances/src/components/datagrid/cell/LoadingCell.spec.tsx b/packages/manager/apps/pci-instances/src/components/datagrid/cell/LoadingCell.spec.tsx new file mode 100644 index 000000000000..280558c55bb7 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/datagrid/cell/LoadingCell.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import { describe, test } from 'vitest'; +import { LoadingCell } from './LoadingCell.component'; + +describe('Considering the loading cell component', () => { + test.each` + isLoading + ${false} + ${true} + `( + 'Should render component correctly depending on isLoading property <$isLoading>', + ({ isLoading }) => { + render( + +
Foo
+
, + ); + const loadingCellElement = screen.getByTestId('loading-cell'); + expect(loadingCellElement).toBeInTheDocument(); + if (isLoading) { + const skeletonElement = screen.getByTestId('skeleton'); + expect(skeletonElement).toBeTruthy(); + } else { + const childrenElement = screen.getByText('Foo'); + expect(childrenElement).toBeTruthy(); + } + }, + ); +}); diff --git a/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.spec.tsx b/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.spec.tsx new file mode 100644 index 000000000000..1eb845abd1b5 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.spec.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { describe } from 'vitest'; +import { BaseTextCell, TextCell } from './TextCell.component'; + +describe('Considering the text cell components', () => { + test('Should render BaseTextCell component correctly with children', () => { + render( + +
Foo
+
, + ); + const baseTextCellElement = screen.getByTestId('base-text-cell'); + expect(baseTextCellElement).toBeInTheDocument(); + const childElement = screen.getByText('Foo'); + expect(childElement).toBeInTheDocument(); + }); + + test('Should render TextCell component correctly with label', () => { + render(); + const textCellElement = screen.getByText('foo'); + expect(textCellElement).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/apps/pci-instances/src/components/menu/ActionsMenu.spec.tsx b/packages/manager/apps/pci-instances/src/components/menu/ActionsMenu.spec.tsx new file mode 100644 index 000000000000..e08c50820568 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/menu/ActionsMenu.spec.tsx @@ -0,0 +1,42 @@ +import { describe, test, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ActionsMenu, TActionsMenuItem } from './ActionsMenu.component'; + +const onMenuItemClickMock = vi.fn(); + +const testItems: TActionsMenuItem[] = [ + { + label: 'foo', + href: '/foo/bar', + }, + { + label: 'bar', + onMenuItemClick: onMenuItemClickMock, + }, +]; + +const renderActionsMenu = (items: TActionsMenuItem[]) => { + render(); +}; + +describe('Considering the ActionsMenu component', () => { + test('Should render only action menu button with Icon as first child if items prop is []', () => { + renderActionsMenu([]); + const actionsMenuButtonElement = screen.getByTestId('actions-menu-button'); + expect(actionsMenuButtonElement).toBeInTheDocument(); + const childElements = actionsMenuButtonElement.querySelectorAll('*'); + expect(childElements.length).toBe(1); + }); + + test('Should render the list of items correctly if provided', () => { + renderActionsMenu(testItems); + const menuItemElements = screen.getAllByTestId('actions-menu-item'); + expect(menuItemElements.length).toBe(2); + const [firstChild, secondChild] = menuItemElements; + expect(firstChild).toHaveTextContent('foo'); + expect(firstChild).toHaveAttribute('href', '/foo/bar'); + expect(secondChild).toHaveTextContent('bar'); + fireEvent.click(secondChild); + expect(onMenuItemClickMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/apps/pci-instances/src/components/navigation/GoBack.spec.tsx b/packages/manager/apps/pci-instances/src/components/navigation/GoBack.spec.tsx new file mode 100644 index 000000000000..ed0ecc7078b4 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/components/navigation/GoBack.spec.tsx @@ -0,0 +1,33 @@ +import { describe, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ODS_ICON_NAME } from '@ovhcloud/ods-components'; +import { GoBack } from './GoBack.component'; + +const backHref = '..'; + +vi.mock('react-router-dom', () => ({ + useHref: () => backHref, +})); + +const getGoBackElement = () => { + render(); + return screen.getByText('pci_instances_common_go_back'); +}; + +describe('Considering the GoBack component', () => { + test('Should be rendered with correct class and href attribute', () => { + const goBackElement = getGoBackElement(); + expect(goBackElement).toBeInTheDocument(); + expect(goBackElement).toHaveClass('mt-12 mb-3'); + expect(goBackElement).toHaveAttribute('href', backHref); + }); + + test('Should contain icon as first child element', () => { + const goBackElement = getGoBackElement(); + expect(goBackElement.firstChild).toBeTruthy(); + expect(goBackElement.firstChild).toHaveAttribute( + 'name', + ODS_ICON_NAME.ARROW_LEFT, + ); + }); +}); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.spec.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.spec.tsx new file mode 100644 index 000000000000..232685f05a1b --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.spec.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react'; +import { describe, vi } from 'vitest'; +import { ActionsCell } from './ActionsCell.component'; +import { mockedInstance } from '@/__mocks__/instance/constants'; + +vi.mock('react-router-dom', () => ({ + useHref: () => mockedInstance.id, +})); + +describe('Considering the ActionsCell component', () => { + test('Should render component correctly', () => { + render(); + const actionsMenuElement = screen.getByTestId('actions-menu-button'); + expect(actionsMenuElement).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/AddressesCell.spec.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/AddressesCell.spec.tsx new file mode 100644 index 000000000000..a2d08f4e487b --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/AddressesCell.spec.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { describe, test } from 'vitest'; +import { AddressesCell } from './AddressesCell.component'; +import { TAddress } from '@/data/hooks/instance/useInstances'; + +const addresses: TAddress[] = [ + { + ip: '123456', + version: 1, + gatewayIp: '', + }, + { + ip: '78910', + version: 2, + gatewayIp: '', + }, +]; + +describe('Considering the AddressesCell component', () => { + test('Should render the component with given addresses', () => { + render(); + addresses.forEach((address) => { + expect(screen.getByText(address.ip)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/NameIdCell.spec.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/NameIdCell.spec.tsx new file mode 100644 index 000000000000..bb5c8b5269c4 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/NameIdCell.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { describe, test, vi } from 'vitest'; +import { NameIdCell } from './NameIdCell.component'; +import { mockedInstance } from '@/__mocks__/instance/constants'; + +vi.mock('react-router-dom', () => ({ + useHref: () => mockedInstance.id, +})); + +describe('Considering the NameIdCell component', () => { + test('Should render component with correct href and labels', () => { + render(); + const nameElement = screen.getByText(mockedInstance.name); + expect(nameElement).toBeInTheDocument(); + expect(nameElement).toHaveAttribute('href', mockedInstance.id); + const idElement = screen.getByText(mockedInstance.id); + expect(idElement).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/StatusCell.spec.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/StatusCell.spec.tsx new file mode 100644 index 000000000000..f5b68a0f2133 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/StatusCell.spec.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/react'; +import { describe, test } from 'vitest'; +import { mockedInstance } from '@/__mocks__/instance/constants'; +import { StatusCell } from './StatusCell.component'; + +describe('Considering the StatusCell component', () => { + test('Should render component correctly', () => { + render(); + const statusCellElement = screen.getByTestId('status-chip'); + expect(statusCellElement).toBeInTheDocument(); + }); +}); From 3094c8431d5554998d487b22bedb87c8ab0a14ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 18 Oct 2024 10:49:53 +0200 Subject: [PATCH 66/76] feat(pci-instances): update i18n translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref:TAPC-1928 Signed-off-by: Frédéric Vilcot --- .../translations/common/Messages_fr_FR.json | 3 ++- .../translations/list/Messages_fr_FR.json | 25 +++++++++---------- .../datagrid/cell/TextCell.component.tsx | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json index da2ed9650b89..7599571f53ac 100644 --- a/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json @@ -2,5 +2,6 @@ "pci_instances_common_instances_title": "Instances", "pci_instances_common_create_instance": "Créer une instance", "pci_instances_common_new": "Nouveau", - "pci_instances_common_go_back": "Revenir à la page précédente" + "pci_instances_common_go_back": "Revenir à la page précédente", + "pci_instances_common_filter": "Filtrer" } diff --git a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json index 16be1a44327d..4bfa9d431deb 100644 --- a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json @@ -1,15 +1,14 @@ { - "nameId": "Nom/ID", - "status": "Statut", - "public_IPs": "IP publique(s)", - "private_IPs": "IP privée(s)", - "cancel": "Annuler", - "image": "Image", - "flavor": "Modèle", - "region": "Localisation", - "filter": "Filtrer", - "refresh": "Rafraîchir", - "inconsistency_message": "Toutes les données n'ont pas pu être chargées. Notre équipe technique est informée et travaille à résoudre le problème.", - "unknown_error_message1": "Nous n'avons pas pu récupérer la liste de vos instances. Veuillez cliquer sur le bouton 'Rafraîchir' pour essayer à nouveau.", - "unknown_error_message2": "Nos équipes techniques ont été notifiées de ce problème et travaillent à sa résolution." + "pci_instances_list_column_nameId": "Nom/ID", + "pci_instances_list_column_status": "Statut", + "pci_instances_list_column_public_IPs": "IP publique(s)", + "pci_instances_list_column_private_IPs": "IP privée(s)", + "pci_instances_list_column_image": "Image", + "pci_instances_list_column_flavor": "Modèle", + "pci_instances_list_column_region": "Localisation", + "pci_instances_list_column_actions": "Actions", + "pci_instances_list_inconsistency_message": "Toutes les données n'ont pas pu être chargées. Notre équipe technique est informée et travaille à résoudre le problème.", + "pci_instances_list_unknown_error_message1": "Nous n'avons pas pu récupérer la liste de vos instances. Veuillez cliquer sur le bouton 'Rafraîchir' pour essayer à nouveau.", + "pci_instances_list_unknown_error_message2": "Nos équipes techniques ont été notifiées de ce problème et travaillent à sa résolution.", + "pci_instances_list_action_instance_details": "Détails de l'instance" } diff --git a/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.component.tsx b/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.component.tsx index 7c172d0bb0b8..a2c9bfc901da 100644 --- a/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.component.tsx +++ b/packages/manager/apps/pci-instances/src/components/datagrid/cell/TextCell.component.tsx @@ -26,6 +26,6 @@ type TTextCellProps = { export const TextCell: FC = ({ isLoading, label }) => ( - {label} + {label} ); From 4169532196038d7949be0f3157ad9bf479cbeb86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Fri, 18 Oct 2024 15:47:47 +0200 Subject: [PATCH 67/76] feat(pci-instances): bump pci-common to 0.7.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Frédéric Vilcot --- packages/manager/apps/pci-instances/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/apps/pci-instances/package.json b/packages/manager/apps/pci-instances/package.json index 74f83da0eca7..964291ad2c3f 100644 --- a/packages/manager/apps/pci-instances/package.json +++ b/packages/manager/apps/pci-instances/package.json @@ -18,7 +18,7 @@ "dependencies": { "@ovh-ux/manager-config": "^7.3.2", "@ovh-ux/manager-core-api": "^0.8.0", - "@ovh-ux/manager-pci-common": "^0.6.1", + "@ovh-ux/manager-pci-common": "^0.7.2", "@ovh-ux/manager-react-components": "^1.26.0", "@ovh-ux/manager-react-core-application": "^0.10.0", "@ovh-ux/manager-react-shell-client": "^0.7.0", From 43a5667c6d56bd310e3a8acef11828026d7dce9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 31 Oct 2024 09:31:06 +0100 Subject: [PATCH 68/76] feat(pci-instances): add new delete route & outlet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2054 Signed-off-by: Frédéric Vilcot --- .../src/pages/instances/Instances.page.tsx | 214 +++++++++--------- .../apps/pci-instances/src/routes/routes.tsx | 9 + 2 files changed, 118 insertions(+), 105 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index 97f77c5377d4..3a79b1aa0e8e 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -1,6 +1,7 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Navigate, + Outlet, useHref, useParams, useRouteLoaderData, @@ -267,118 +268,121 @@ const Instances: FC = () => { return ; return ( - - {project && } -
-
- {t('common:pci_instances_common_instances_title')} - + <> + + {project && } +
+
+ {t('common:pci_instances_common_instances_title')} + +
-
-
- - - -
- - - - {t('common:pci_instances_common_create_instance')} - - -
-
- +
+ + + +
+ + - -
- 0 || isFetching} - onOdsSearchSubmit={handleOdsSearchSubmit} - /> - - 0 || isFetching) && { - disabled: true, - })} - > - {t('common:pci_instances_common_create_instance')} + + +
+
+ - {t('common:pci_instances_common_filter')} - - - { - addFilter({ - ...addedFilter, - label: column.id, - }); - filterPopoverRef.current?.closeSurface(); - }} - /> - - -
-
-
- -
- {data && ( -
- + variant={ODS_BUTTON_VARIANT.stroked} + onClick={handleRefresh} + {...(isFetching && { disabled: true })} + > + + +
+ 0 || isFetching} + onOdsSearchSubmit={handleOdsSearchSubmit} + /> + + 0 || isFetching) && { + disabled: true, + })} + > + + {t('common:pci_instances_common_filter')} + + + { + addFilter({ + ...addedFilter, + label: column.id, + }); + filterPopoverRef.current?.closeSurface(); + }} + /> + + +
- )} - {isFetchingNextPage && ( -
- +
+
- )} -
- + {data && ( +
+ +
+ )} + {isFetchingNextPage && ( +
+ +
+ )} +
+ + + ); }; diff --git a/packages/manager/apps/pci-instances/src/routes/routes.tsx b/packages/manager/apps/pci-instances/src/routes/routes.tsx index b7ec5aa3f4a8..0ad5d9b79d62 100644 --- a/packages/manager/apps/pci-instances/src/routes/routes.tsx +++ b/packages/manager/apps/pci-instances/src/routes/routes.tsx @@ -18,6 +18,7 @@ export const SUB_PATHS = { onboarding: 'onboarding', new: 'new', instance: ':instanceId', + delete: 'delete', }; const routes: RouteObject[] = [ @@ -35,6 +36,14 @@ const routes: RouteObject[] = [ { path: '', ...lazyRouteConfig(() => import('@/pages/instances/Instances.page')), + children: [ + { + path: SUB_PATHS.delete, + ...lazyRouteConfig(() => + import('@/pages/instances/delete/DeleteInstance.page'), + ), + }, + ], }, { path: SUB_PATHS.onboarding, From 4846948aa011f6c1e42403c2c69b735151c3bd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 31 Oct 2024 09:33:27 +0100 Subject: [PATCH 69/76] feat(pci-instances): add translations for delete purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2054 Signed-off-by: Frédéric Vilcot --- .../public/translations/common/Messages_fr_FR.json | 4 +++- .../public/translations/delete/Messages_fr_FR.json | 6 ++++++ .../public/translations/list/Messages_fr_FR.json | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 packages/manager/apps/pci-instances/public/translations/delete/Messages_fr_FR.json diff --git a/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json index 7599571f53ac..9aae66ba0ce9 100644 --- a/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/common/Messages_fr_FR.json @@ -3,5 +3,7 @@ "pci_instances_common_create_instance": "Créer une instance", "pci_instances_common_new": "Nouveau", "pci_instances_common_go_back": "Revenir à la page précédente", - "pci_instances_common_filter": "Filtrer" + "pci_instances_common_filter": "Filtrer", + "pci_instances_common_cancel": "Annuler", + "pci_instances_common_confirm": "Confirmer" } diff --git a/packages/manager/apps/pci-instances/public/translations/delete/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/delete/Messages_fr_FR.json new file mode 100644 index 000000000000..63fc26cac5c6 --- /dev/null +++ b/packages/manager/apps/pci-instances/public/translations/delete/Messages_fr_FR.json @@ -0,0 +1,6 @@ +{ + "pci_instances_delete_instance_title": "Supprimer une instance", + "pci_instances_delete_instance_confirmation_message": "Êtes-vous sûr de vouloir supprimer l'instance {{ name }} ?", + "pci_instances_delete_instance_error_message": "Une erreur est survenue lors de la suppression de l'instance {{ name }}.", + "pci_instances_delete_instance_success_message": "L'instance {{ name }} a bien été supprimée." +} diff --git a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json index 4bfa9d431deb..6ee4ad7945c8 100644 --- a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json @@ -10,5 +10,6 @@ "pci_instances_list_inconsistency_message": "Toutes les données n'ont pas pu être chargées. Notre équipe technique est informée et travaille à résoudre le problème.", "pci_instances_list_unknown_error_message1": "Nous n'avons pas pu récupérer la liste de vos instances. Veuillez cliquer sur le bouton 'Rafraîchir' pour essayer à nouveau.", "pci_instances_list_unknown_error_message2": "Nos équipes techniques ont été notifiées de ce problème et travaillent à sa résolution.", - "pci_instances_list_action_instance_details": "Détails de l'instance" + "pci_instances_list_action_instance_details": "Détails de l'instance", + "pci_instances_list_action_delete_instance": "Supprimer" } From 1160c9840c8437151e7bff999b1ffbdea5d3af32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 5 Nov 2024 16:13:12 +0100 Subject: [PATCH 70/76] feat(pci-instances): add delete instance logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2054 Signed-off-by: Frédéric Vilcot --- .../pci-instances/src/data/api/instance.ts | 6 ++++ .../data/hooks/instance/useDeleteInstance.ts | 30 +++++++++++++++++++ .../src/data/hooks/instance/useInstances.ts | 21 +++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/instance/useDeleteInstance.ts diff --git a/packages/manager/apps/pci-instances/src/data/api/instance.ts b/packages/manager/apps/pci-instances/src/data/api/instance.ts index 3c0efad4f644..5e1494e77517 100644 --- a/packages/manager/apps/pci-instances/src/data/api/instance.ts +++ b/packages/manager/apps/pci-instances/src/data/api/instance.ts @@ -27,3 +27,9 @@ export const getInstances = ( }, }) .then((response) => response.data); + +export const deleteInstance = ( + projectId: string, + instanceId: string, +): Promise => + v6.delete(`/cloud/project/${projectId}/instance/${instanceId}`); diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instance/useDeleteInstance.ts b/packages/manager/apps/pci-instances/src/data/hooks/instance/useDeleteInstance.ts new file mode 100644 index 000000000000..cda9859a94d2 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/instance/useDeleteInstance.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query'; +import { deleteInstance } from '@/data/api/instance'; +import { DeepReadonly } from '@/types/utils.type'; +import { instancesQueryKey } from '@/utils'; + +export type TUseDeleteInstanceCallbacks = DeepReadonly<{ + onSuccess?: () => void; + onError?: (error: unknown) => void; +}>; + +export const useDeleteInstance = ( + projectId: string, + { onError, onSuccess }: TUseDeleteInstanceCallbacks = {}, +) => { + const mutationKey = instancesQueryKey(projectId, ['instance', 'delete']); + const mutationFn = (instanceId: string) => + deleteInstance(projectId, instanceId); + + const mutation = useMutation({ + mutationKey, + mutationFn, + onError, + onSuccess, + }); + + return { + deleteInstance: mutation.mutate, + ...mutation, + }; +}; diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts index 63658a63f27f..ea2c44a8c261 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts @@ -2,6 +2,7 @@ import { InfiniteData, keepPreviousData, Query, + QueryClient, QueryKey, useInfiniteQuery, useQueryClient, @@ -47,6 +48,26 @@ export type TInstance = DeepReadonly<{ addresses: Map; }>; +export const updateDeletedInstanceStatus = ( + queryClient: QueryClient, + instanceId: string, +) => { + queryClient.setQueriesData>( + { predicate: (query: Query) => query.queryKey.includes('list') }, + (prevData) => { + if (!prevData) return undefined; + const updatedPages = prevData.pages.map((page) => + page.map((instance) => + instance.id === instanceId + ? { ...instance, status: 'DELETING' as TInstanceStatusDto } + : instance, + ), + ); + return { ...prevData, pages: updatedPages }; + }, + ); +}; + const buildInstanceStatusSeverity = ( status: TInstanceStatusDto, ): TInstanceStatusSeverity => { From 842d6c0e73aaff3b2d220cb56a8a23f94a69fd53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 5 Nov 2024 16:13:54 +0100 Subject: [PATCH 71/76] feat(pci-instances): add delete modal & update actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2054 Signed-off-by: Frédéric Vilcot --- .../datagrid/cell/ActionsCell.component.tsx | 6 ++ .../instances/delete/DeleteInstance.page.tsx | 65 +++++++++++++++++ .../delete/modal/DeleteModal.component.tsx | 70 +++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/delete/DeleteInstance.page.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/delete/modal/DeleteModal.component.tsx diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx index d431b6f3b468..8bfefe82d294 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx @@ -15,12 +15,18 @@ type TActionsCellProps = { export const ActionsCell: FC = ({ isLoading, instance }) => { const { t } = useTranslation('list'); + const deleteHref = `delete?instanceId=${instance.id}&instanceName=${instance.name}`; const items: TActionsMenuItem[] = [ { label: t('pci_instances_list_action_instance_details'), href: useHref(instance.id), }, + { + label: t('pci_instances_list_action_delete_instance'), + href: useHref(deleteHref), + }, ]; + return ( diff --git a/packages/manager/apps/pci-instances/src/pages/instances/delete/DeleteInstance.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/delete/DeleteInstance.page.tsx new file mode 100644 index 000000000000..538885c5efb4 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/delete/DeleteInstance.page.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useNotifications } from '@ovh-ux/manager-react-components'; +import { useDeleteInstance } from '@/data/hooks/instance/useDeleteInstance'; +import { updateDeletedInstanceStatus } from '@/data/hooks/instance/useInstances'; +import queryClient from '@/queryClient'; +import { DeleteModal } from './modal/DeleteModal.component'; + +const DeleteInstance: FC = () => { + const { t } = useTranslation(['delete', 'common']); + const navigate = useNavigate(); + const { projectId } = useParams() as { projectId: string }; + const [searchParams] = useSearchParams(); + const instanceId = searchParams.get('instanceId'); + const instanceName = searchParams.get('instanceName'); + const { addError, addSuccess } = useNotifications(); + + const handleModalClose = () => { + navigate('..'); + }; + + const handleMutationSuccess = () => { + if (instanceId) { + updateDeletedInstanceStatus(queryClient, instanceId); + } + addSuccess( + t('pci_instances_delete_instance_success_message', { + name: instanceName, + }), + true, + ); + handleModalClose(); + }; + + const handleMutationError = () => { + addError( + t('pci_instances_delete_instance_error_message', { name: instanceName }), + true, + ); + handleModalClose(); + }; + + const { deleteInstance, isPending } = useDeleteInstance(projectId, { + onError: handleMutationError, + onSuccess: handleMutationSuccess, + }); + + const handleDeleteInstance = () => { + deleteInstance(instanceId as string); + }; + + if (!instanceId || !instanceName) return null; + + return ( + + ); +}; + +export default DeleteInstance; diff --git a/packages/manager/apps/pci-instances/src/pages/instances/delete/modal/DeleteModal.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/delete/modal/DeleteModal.component.tsx new file mode 100644 index 000000000000..69fef61de954 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/delete/modal/DeleteModal.component.tsx @@ -0,0 +1,70 @@ +import { + ODS_THEME_COLOR_INTENT, + ODS_THEME_TYPOGRAPHY_LEVEL, +} from '@ovhcloud/ods-common-theming'; +import { ODS_BUTTON_VARIANT, ODS_TEXT_SIZE } from '@ovhcloud/ods-components'; +import { + OsdsButton, + OsdsModal, + OsdsText, +} from '@ovhcloud/ods-components/react'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Spinner } from '@/components/spinner/Spinner.component'; +import { DeepReadonly } from '@/types/utils.type'; + +type TDeleteModalProps = DeepReadonly<{ + instanceName: string; + isPending: boolean; + onModalClose: () => void; + onModalConfirm: () => void; +}>; + +export const DeleteModal: FC = ({ + instanceName, + isPending, + onModalClose, + onModalConfirm, +}) => { + const { t } = useTranslation(['delete', 'common']); + + return ( + + + {isPending ? ( + + ) : ( + + {t('pci_instances_delete_instance_confirmation_message', { + name: instanceName, + })} + + )} + + + {t('common:pci_instances_common_cancel')} + + + {t('common:pci_instances_common_confirm')} + + + ); +}; From be1c5faf3da25788d6da25eac7a1a93b45fd294e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 5 Nov 2024 16:14:34 +0100 Subject: [PATCH 72/76] test(pci-instances): add test suite & update msw handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2054 Signed-off-by: Frédéric Vilcot --- .../src/__mocks__/instance/handlers.ts | 43 ++++- .../src/__mocks__/instance/node.ts | 9 +- .../hooks/instance/useDeleteInstance.spec.tsx | 157 ++++++++++++++++++ .../data/hooks/instance/useInstances.spec.tsx | 11 +- .../delete/modal/DeleteModal.spec.tsx | 47 ++++++ 5 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 packages/manager/apps/pci-instances/src/data/hooks/instance/useDeleteInstance.spec.tsx create mode 100644 packages/manager/apps/pci-instances/src/pages/instances/delete/modal/DeleteModal.spec.tsx diff --git a/packages/manager/apps/pci-instances/src/__mocks__/instance/handlers.ts b/packages/manager/apps/pci-instances/src/__mocks__/instance/handlers.ts index 5a76f92ef5c2..c45f846c9aac 100644 --- a/packages/manager/apps/pci-instances/src/__mocks__/instance/handlers.ts +++ b/packages/manager/apps/pci-instances/src/__mocks__/instance/handlers.ts @@ -1,11 +1,36 @@ import { http, HttpResponse, JsonBodyType, RequestHandler } from 'msw'; +import { DeepReadonly } from '@/types/utils.type'; -export const instancesHandlers = ( - mockedResponsePayload?: T, -): RequestHandler[] => [ - http.get('*/cloud/project/:projectId/aggregated/instance', async () => { - return !mockedResponsePayload - ? new HttpResponse(null, { status: 500 }) - : HttpResponse.json(mockedResponsePayload); - }), -]; +export type TInstancesServerResponse = DeepReadonly<{ + method: 'get' | 'delete'; + payload: JsonBodyType; +}>; + +const rootUri = '*/cloud/project/:projectId'; + +const getResponsePayload = (payload: JsonBodyType): HttpResponse => + payload === undefined + ? new HttpResponse(null, { status: 500 }) + : HttpResponse.json(payload); + +const getResponseEndpoint = ( + method: TInstancesServerResponse['method'], +): string => { + switch (method) { + case 'get': + return `${rootUri}/aggregated/instance`; + case 'delete': + return `${rootUri}/instance/:instanceId`; + default: + return ''; + } +}; + +export const instancesHandlers = ( + response: TInstancesServerResponse[], +): RequestHandler[] => + response.map(({ method, payload }) => + http[method](getResponseEndpoint(method), async () => + getResponsePayload(payload), + ), + ); diff --git a/packages/manager/apps/pci-instances/src/__mocks__/instance/node.ts b/packages/manager/apps/pci-instances/src/__mocks__/instance/node.ts index 2a54c0e13045..dea913b42515 100644 --- a/packages/manager/apps/pci-instances/src/__mocks__/instance/node.ts +++ b/packages/manager/apps/pci-instances/src/__mocks__/instance/node.ts @@ -1,11 +1,8 @@ -import { JsonBodyType } from 'msw'; import { setupServer } from 'msw/node'; -import { instancesHandlers } from './handlers'; +import { instancesHandlers, TInstancesServerResponse } from './handlers'; -export const setupInstanceServer = ( - mockedResponsePayload?: T, -) => { - const server = setupServer(...instancesHandlers(mockedResponsePayload)); +export const setupInstancesServer = (response: TInstancesServerResponse[]) => { + const server = setupServer(...instancesHandlers(response)); server.listen({ onUnhandledRequest: 'error', }); diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instance/useDeleteInstance.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instance/useDeleteInstance.spec.tsx new file mode 100644 index 000000000000..ba9f897e8972 --- /dev/null +++ b/packages/manager/apps/pci-instances/src/data/hooks/instance/useDeleteInstance.spec.tsx @@ -0,0 +1,157 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SetupServer } from 'msw/lib/node'; +import { FC, PropsWithChildren } from 'react'; +import { describe, test, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import { + TInstance, + updateDeletedInstanceStatus, + useInstances, +} from './useInstances'; +import { setupInstancesServer } from '@/__mocks__/instance/node'; +import { useDeleteInstance } from './useDeleteInstance'; +import { TInstanceDto } from '@/types/instance/api.types'; +import { TInstancesServerResponse } from '@/__mocks__/instance/handlers'; + +// initializers +const initQueryClient = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const wrapper: FC = ({ children }) => ( + {children} + ); + return { wrapper, queryClient }; +}; + +// test data +type Data = { + projectId: string; + instanceId: string; + queryPayload?: TInstanceDto[]; + mutationPayload?: null; +}; + +const fakeProjectId = '8c8c4fd6d4414aa29fc777752b00005198664'; + +const fakeInstancesDto: TInstanceDto[] = [ + { + id: `fake-id-1`, + name: `fake-instance-name-1`, + flavorId: `fake-flavor-id-1`, + flavorName: `fake-flavor-name-1`, + imageId: `fake-image-id-1`, + imageName: `fake-image-name-1`, + region: `fake-region-1`, + status: 'ACTIVE', + addresses: [], + }, + { + id: `fake-id-2`, + name: `fake-instance-name-2`, + flavorId: `fake-flavor-id-2`, + flavorName: `fake-flavor-name-2`, + imageId: `fake-image-id-2`, + imageName: `fake-image-name-2`, + region: `fake-region-2`, + status: 'ACTIVE', + addresses: [], + }, +]; + +// msw server +let server: SetupServer; + +// mocks +const handleError = vi.fn(); +const handleSuccess = vi.fn( + (instanceId: string, queryClient: QueryClient) => () => + updateDeletedInstanceStatus(queryClient, instanceId), +); + +describe('Considering the useDeleteInstance hook', () => { + describe.each` + projectId | instanceId | queryPayload | mutationPayload + ${fakeProjectId} | ${'fake-id-1'} | ${undefined} | ${undefined} + ${fakeProjectId} | ${'fake-id-1'} | ${fakeInstancesDto} | ${undefined} + ${fakeProjectId} | ${'fake-id-1'} | ${fakeInstancesDto} | ${null} + `( + 'Given a projectId <$projectId> and an instanceId <$instanceId>', + ({ projectId, instanceId, queryPayload, mutationPayload }: Data) => { + afterEach(() => { + server?.close(); + }); + test("When invoking deleteInstance() mutate's function", async () => { + const serverResponse: TInstancesServerResponse[] = [ + { + method: 'get', + payload: queryPayload, + }, + { + method: 'delete', + payload: mutationPayload, + }, + ]; + server = setupInstancesServer(serverResponse); + + const { wrapper, queryClient } = initQueryClient(); + + const { result: useInstancesResult } = renderHook( + () => + useInstances(projectId, { + limit: 10, + sort: 'name', + sortOrder: 'asc', + filters: [], + }), + { + wrapper, + }, + ); + + const { result: useDeleteInstanceResult } = renderHook( + () => + useDeleteInstance(projectId, { + onSuccess: handleSuccess(instanceId, queryClient), + onError: handleError, + }), + { + wrapper, + }, + ); + + await waitFor(() => + expect(useInstancesResult.current.isPending).toBe(false), + ); + expect(useDeleteInstanceResult.current.isIdle).toBeTruthy(); + act(() => useDeleteInstanceResult.current.deleteInstance(instanceId)); + + if (mutationPayload === undefined) { + await waitFor(() => + expect(useDeleteInstanceResult.current.isError).toBeTruthy(), + ); + expect(useDeleteInstanceResult.current.error).toHaveProperty( + 'response.status', + 500, + ); + expect(useDeleteInstanceResult.current.error).instanceOf(AxiosError); + expect(handleError).toHaveBeenCalled(); + } else { + await waitFor(() => + expect(useDeleteInstanceResult.current.isSuccess).toBeTruthy(), + ); + const deletedInstance = (useInstancesResult.current + .data as TInstance[]).find((elt) => elt.id === instanceId); + expect(handleSuccess).toHaveBeenCalled(); + expect(deletedInstance).toBeDefined(); + expect(deletedInstance?.status.state).toStrictEqual('DELETING'); + } + }); + }, + ); +}); diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.spec.tsx b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.spec.tsx index 60db928f560c..d1f495f1c16f 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.spec.tsx +++ b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.spec.tsx @@ -11,8 +11,9 @@ import { useInstances, TUseInstancesQueryParams, } from './useInstances'; -import { setupInstanceServer } from '@/__mocks__/instance/node'; import { TInstanceDto, TInstanceStatusDto } from '@/types/instance/api.types'; +import { setupInstancesServer } from '@/__mocks__/instance/node'; +import { TInstancesServerResponse } from '@/__mocks__/instance/handlers'; // builders const instanceDtoBuilder = ( @@ -233,7 +234,13 @@ describe('UseInstances hook', () => { test(`When invoking useInstances() hook', then, expect the computed instances to be '${JSON.stringify( expectedInstances, )}' and the query hasNext property to be ${expectedQueryHasNext}`, async () => { - server = setupInstanceServer(queryPayload); + const serverResponse: TInstancesServerResponse[] = [ + { + method: 'get', + payload: queryPayload, + }, + ]; + server = setupInstancesServer(serverResponse); const { wrapper, queryClient } = initQueryClient(); const { result } = renderHook( diff --git a/packages/manager/apps/pci-instances/src/pages/instances/delete/modal/DeleteModal.spec.tsx b/packages/manager/apps/pci-instances/src/pages/instances/delete/modal/DeleteModal.spec.tsx new file mode 100644 index 000000000000..202c0a49f80c --- /dev/null +++ b/packages/manager/apps/pci-instances/src/pages/instances/delete/modal/DeleteModal.spec.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, vi, test } from 'vitest'; +import { DeleteModal } from './DeleteModal.component'; + +const handleModalClose = vi.fn(); +const handleModalConfirm = vi.fn(); + +const initialProps = { + instanceName: 'foo', + isPending: false, + onModalClose: handleModalClose, + onModalConfirm: handleModalConfirm, +}; + +const initRender = (isLoading?: boolean) => { + render( + , + ); +}; + +describe('Regarding the DeleteModal component', () => { + test('should render a Spinner if component is in pending state', () => { + initRender(true); + const spinnerElement = screen.getByTestId('spinner'); + expect(spinnerElement).toBeInTheDocument(); + }); + test('Should render the component with given props', () => { + initRender(); + const modalElement = screen.getByText( + 'pci_instances_delete_instance_confirmation_message', + ); + expect(modalElement).toBeInTheDocument(); + const confirmButtonELement = screen.getByText( + 'common:pci_instances_common_confirm', + ); + const cancelButtonELement = screen.getByText( + 'common:pci_instances_common_cancel', + ); + fireEvent.click(confirmButtonELement); + expect(handleModalConfirm).toHaveBeenCalled(); + fireEvent.click(cancelButtonELement); + expect(handleModalClose).toHaveBeenCalled(); + }); +}); From fa3e14b8db7f4be584b0de03fcd33a5f967c9b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Tue, 5 Nov 2024 17:26:01 +0100 Subject: [PATCH 73/76] feat(pci-instances): improve code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2054 Signed-off-by: Frédéric Vilcot --- .../pci-instances/src/data/hooks/instance/useInstances.ts | 3 ++- .../src/pages/instances/delete/DeleteInstance.page.tsx | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts index ea2c44a8c261..61215465d876 100644 --- a/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts +++ b/packages/manager/apps/pci-instances/src/data/hooks/instance/useInstances.ts @@ -50,8 +50,9 @@ export type TInstance = DeepReadonly<{ export const updateDeletedInstanceStatus = ( queryClient: QueryClient, - instanceId: string, + instanceId?: string | null, ) => { + if (!instanceId) return; queryClient.setQueriesData>( { predicate: (query: Query) => query.queryKey.includes('list') }, (prevData) => { diff --git a/packages/manager/apps/pci-instances/src/pages/instances/delete/DeleteInstance.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/delete/DeleteInstance.page.tsx index 538885c5efb4..9d4e5aaccf0b 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/delete/DeleteInstance.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/delete/DeleteInstance.page.tsx @@ -21,9 +21,7 @@ const DeleteInstance: FC = () => { }; const handleMutationSuccess = () => { - if (instanceId) { - updateDeletedInstanceStatus(queryClient, instanceId); - } + updateDeletedInstanceStatus(queryClient, instanceId); addSuccess( t('pci_instances_delete_instance_success_message', { name: instanceName, From 635f0e5add57736c7f21434db024625d0a65de39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 7 Nov 2024 15:38:50 +0100 Subject: [PATCH 74/76] build(pci-instances): bump @ovh-ux/* packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2160 Signed-off-by: Frédéric Vilcot --- packages/manager/apps/pci-instances/package.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/manager/apps/pci-instances/package.json b/packages/manager/apps/pci-instances/package.json index 964291ad2c3f..c6ade53c66a5 100644 --- a/packages/manager/apps/pci-instances/package.json +++ b/packages/manager/apps/pci-instances/package.json @@ -16,12 +16,12 @@ "type:check": "tsc --noEmit" }, "dependencies": { - "@ovh-ux/manager-config": "^7.3.2", - "@ovh-ux/manager-core-api": "^0.8.0", - "@ovh-ux/manager-pci-common": "^0.7.2", - "@ovh-ux/manager-react-components": "^1.26.0", - "@ovh-ux/manager-react-core-application": "^0.10.0", - "@ovh-ux/manager-react-shell-client": "^0.7.0", + "@ovh-ux/manager-config": "^7.5.3-alpha.0", + "@ovh-ux/manager-core-api": "^0.9.0-alpha.0", + "@ovh-ux/manager-pci-common": "^0.8.0-alpha.6", + "@ovh-ux/manager-react-components": "^1.41.0-alpha.4", + "@ovh-ux/manager-react-core-application": "^0.10.8-alpha.0", + "@ovh-ux/manager-react-shell-client": "^0.8.0-alpha.0", "@ovh-ux/manager-tailwind-config": "^0.2.0", "@ovhcloud/ods-common-core": "17.2.2", "@ovhcloud/ods-common-stencil": "17.2.2", @@ -38,11 +38,11 @@ "react-dom": "^18.2.0", "react-i18next": "^14.0.5", "react-router-dom": "^6.3.0", - "zustand": "^4.5.0" + "zustand": "^4.5.5" }, "devDependencies": { "@jest/globals": "^29.7.0", - "@ovh-ux/manager-vite-config": "^0.8.0", + "@ovh-ux/manager-vite-config": "^0.8.1", "@tanstack/react-query-devtools": "^5.51.21", "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.4.2", From d855b99322752f4aa2e426468a65380a0b0fbd53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 7 Nov 2024 15:46:56 +0100 Subject: [PATCH 75/76] feat(pci-instances): add autobackup href MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2160 Signed-off-by: Frédéric Vilcot --- .../translations/list/Messages_fr_FR.json | 3 ++- .../src/pages/instances/Instances.page.tsx | 24 ++++++++++++++++--- .../datagrid/cell/ActionsCell.component.tsx | 22 ++++++++++------- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json index 6ee4ad7945c8..8442a60e9eef 100644 --- a/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json +++ b/packages/manager/apps/pci-instances/public/translations/list/Messages_fr_FR.json @@ -11,5 +11,6 @@ "pci_instances_list_unknown_error_message1": "Nous n'avons pas pu récupérer la liste de vos instances. Veuillez cliquer sur le bouton 'Rafraîchir' pour essayer à nouveau.", "pci_instances_list_unknown_error_message2": "Nos équipes techniques ont été notifiées de ce problème et travaillent à sa résolution.", "pci_instances_list_action_instance_details": "Détails de l'instance", - "pci_instances_list_action_delete_instance": "Supprimer" + "pci_instances_list_action_delete_instance": "Supprimer", + "pci_instances_list_action_autobackup": "Créer une sauvegarde automatisée" } diff --git a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx index 3a79b1aa0e8e..2e4e5ed9889a 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/Instances.page.tsx @@ -18,6 +18,7 @@ import { Title, useColumnFilters, useNotifications, + useProjectUrl, useTranslatedMicroRegions, } from '@ovh-ux/manager-react-components'; import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; @@ -44,7 +45,10 @@ import { TInstance, useInstances } from '@/data/hooks/instance/useInstances'; import { Breadcrumb } from '@/components/breadcrumb/Breadcrumb.component'; import { SUB_PATHS } from '@/routes/routes'; import { StatusCell } from './datagrid/cell/StatusCell.component'; -import { ActionsCell } from './datagrid/cell/ActionsCell.component'; +import { + ActionsCell, + TActionsCellHrefs, +} from './datagrid/cell/ActionsCell.component'; import { NameIdCell } from './datagrid/cell/NameIdCell.component'; import { TextCell } from '@/components/datagrid/cell/TextCell.component'; import { AddressesCell } from './datagrid/cell/AddressesCell.component'; @@ -89,6 +93,17 @@ const Instances: FC = () => { filters, }); + const projectUrl = useProjectUrl('public-cloud'); + + const actionsCellHrefs = useCallback( + (instance: TInstance): TActionsCellHrefs => ({ + deleteHref: `delete?instanceId=${instance.id}&instanceName=${instance.name}`, + autobackupHref: `${projectUrl}/workflow/new`, + detailsHref: instance.id, + }), + [projectUrl], + ); + const datagridColumns: DatagridColumn[] = useMemo( () => [ { @@ -159,13 +174,16 @@ const Instances: FC = () => { { id: 'actions', cell: (instance) => ( - + ), label: t('pci_instances_list_column_actions'), isSortable: false, }, ], - [isRefetching, t, translateMicroRegion], + [actionsCellHrefs, isRefetching, t, translateMicroRegion], ); const filterColumns = useMemo( diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx index 8bfefe82d294..94cbf5edad93 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.component.tsx @@ -1,29 +1,35 @@ import { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { useHref } from 'react-router-dom'; -import { TInstance } from '@/data/hooks/instance/useInstances'; import { TActionsMenuItem, ActionsMenu, } from '@/components/menu/ActionsMenu.component'; import { LoadingCell } from '@/components/datagrid/cell/LoadingCell.component'; +import { DeepReadonly } from '@/types/utils.type'; -type TActionsCellProps = { - instance: TInstance; +type TActionsCellHref = 'deleteHref' | 'autobackupHref' | 'detailsHref'; +export type TActionsCellHrefs = Record; + +export type TActionsCellProps = DeepReadonly<{ isLoading: boolean; -}; + hrefs: TActionsCellHrefs; +}>; -export const ActionsCell: FC = ({ isLoading, instance }) => { +export const ActionsCell: FC = ({ isLoading, hrefs }) => { const { t } = useTranslation('list'); - const deleteHref = `delete?instanceId=${instance.id}&instanceName=${instance.name}`; const items: TActionsMenuItem[] = [ { label: t('pci_instances_list_action_instance_details'), - href: useHref(instance.id), + href: useHref(hrefs.detailsHref), + }, + { + label: t('pci_instances_list_action_autobackup'), + href: hrefs.autobackupHref, }, { label: t('pci_instances_list_action_delete_instance'), - href: useHref(deleteHref), + href: useHref(hrefs.deleteHref), }, ]; From a63e560be00454c1eb1047316510b13d5f2964f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Vilcot?= Date: Thu, 7 Nov 2024 15:48:09 +0100 Subject: [PATCH 76/76] test(pci-instances): update actionsCell unitary test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: TAPC-2160 Signed-off-by: Frédéric Vilcot --- .../instances/datagrid/cell/ActionsCell.spec.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.spec.tsx b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.spec.tsx index 232685f05a1b..797679668f04 100644 --- a/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.spec.tsx +++ b/packages/manager/apps/pci-instances/src/pages/instances/datagrid/cell/ActionsCell.spec.tsx @@ -1,15 +1,23 @@ import { render, screen } from '@testing-library/react'; import { describe, vi } from 'vitest'; -import { ActionsCell } from './ActionsCell.component'; -import { mockedInstance } from '@/__mocks__/instance/constants'; +import { ActionsCell, TActionsCellProps } from './ActionsCell.component'; vi.mock('react-router-dom', () => ({ - useHref: () => mockedInstance.id, + useHref: () => 'foo', })); +const initialProps: TActionsCellProps = { + isLoading: false, + hrefs: { + deleteHref: 'foo/delete', + detailsHref: 'foo/details', + autobackupHref: 'foo/autobackup', + }, +}; + describe('Considering the ActionsCell component', () => { test('Should render component correctly', () => { - render(); + render(); const actionsMenuElement = screen.getByTestId('actions-menu-button'); expect(actionsMenuElement).toBeInTheDocument(); });